From 0c04cdbbb142b036310d907c1de5ae7b360e57a7 Mon Sep 17 00:00:00 2001 From: OutstanderWang Date: Fri, 9 Oct 2026 20:43:55 +0800 Subject: [PATCH 01/14] [feat] kv_batch_get: apply an optional parser on the storage units kv_batch_get / async_kv_batch_get take parser(value, parser_arg) plus per-key parser_args. SimpleStorage units apply the parser to every selected (key, field) before serializing the reply, so only the parsed part crosses the network; select_list_items covers picking items out of a list field. - Parser errors come back as RuntimeError naming field, global index and arg. - A parser get requires every key to exist, so args stay aligned with keys. - Replies carry "parsed"; the manager rejects replies from older units. - KV backends raise NotImplementedError instead of fetching whole values. (cherry picked from commit 9c240f3f61c8d58ee6fed056dc5312060ce7f7cf) Signed-off-by: OutstanderWang --- tests/e2e/test_kv_interface_e2e.py | 114 +++++++++++++++++- tests/test_client.py | 4 +- tests/test_get_parser.py | 94 +++++++++++++++ tests/test_kv_storage_manager.py | 11 ++ transfer_queue/__init__.py | 3 + transfer_queue/client.py | 26 +++- transfer_queue/interface.py | 58 ++++++++- transfer_queue/storage/managers/base.py | 24 +++- .../managers/simple_storage_manager.py | 27 ++++- transfer_queue/storage/simple_storage.py | 14 ++- transfer_queue/utils/get_parser.py | 89 ++++++++++++++ 11 files changed, 445 insertions(+), 19 deletions(-) create mode 100644 tests/test_get_parser.py create mode 100644 transfer_queue/utils/get_parser.py diff --git a/tests/e2e/test_kv_interface_e2e.py b/tests/e2e/test_kv_interface_e2e.py index fa5d488b..cc49472d 100644 --- a/tests/e2e/test_kv_interface_e2e.py +++ b/tests/e2e/test_kv_interface_e2e.py @@ -27,7 +27,7 @@ import ray import torch from omegaconf import OmegaConf -from tensordict import TensorDict +from tensordict import NonTensorStack, TensorDict import transfer_queue as tq @@ -728,6 +728,118 @@ def test_kv_batch_get_nonexistent_key(self, controller, tq_api): assert "not found" in str(e).lower() or "empty" in str(e).lower() +class TestKVBatchGetParserE2E: + """End-to-end tests for kv_batch_get with a parser applied on the SimpleStorage units.""" + + @pytest.fixture(autouse=True) + def require_simple_storage(self, backend_name): + if backend_name != "SimpleStorage": + pytest.skip("get parsers only run on SimpleStorage units") + + @staticmethod + def put_image_lists(tq_api, partition_id, keys): + image_lists = [ + [torch.full((3, 2, k + i + 1), float(10 * k + i)) for i in range(k + 2)] for k in range(len(keys)) + ] + fields = TensorDict( + {"images": NonTensorStack(*image_lists), "input_ids": torch.arange(len(keys) * 4).view(len(keys), 4)}, + batch_size=len(keys), + ) + tq_api.kv_batch_put(keys=keys, partition_id=partition_id, fields=fields, tags=[{} for _ in keys]) + return image_lists + + def test_kv_batch_get_parser_selects_list_items_per_key(self, controller, tq_api): + partition_id = "test_partition" + keys = ["parser_0", "parser_1", "parser_2", "parser_3"] + image_lists = self.put_image_lists(tq_api, partition_id, keys) + + retrieved = tq_api.kv_batch_get( + keys=["parser_3", "parser_0", "parser_2"], + partition_id=partition_id, + select_fields="images", + parser=tq.select_list_items, + parser_args=[[4, 0], [1], [3, 3, 1]], + ) + + assert list(retrieved.keys()) == ["images"] + expected = [[image_lists[3][4], image_lists[3][0]], [image_lists[0][1]], [image_lists[2][i] for i in (3, 3, 1)]] + for got_row, expected_row in zip(list(retrieved["images"]), expected, strict=True): + assert len(got_row) == len(expected_row) + for got, want in zip(got_row, expected_row, strict=True): + assert_tensor_equal(got, want) + + full = tq_api.kv_batch_get(keys="parser_3", partition_id=partition_id, select_fields="images") + assert len(full["images"][0]) == len(image_lists[3]) + + def test_kv_batch_get_parser_empty_selection(self, controller, tq_api): + partition_id = "test_partition" + keys = ["parser_empty_0", "parser_empty_1"] + self.put_image_lists(tq_api, partition_id, keys) + + retrieved = tq_api.kv_batch_get( + keys=keys, + partition_id=partition_id, + select_fields="images", + parser=tq.select_list_items, + parser_args=[[], []], + ) + + assert list(retrieved["images"]) == [[], []] + + def test_kv_batch_get_parser_trims_tensor_field_to_variable_lengths(self, controller, tq_api): + partition_id = "test_partition" + keys = ["parser_tensor_0", "parser_tensor_1", "parser_tensor_2"] + self.put_image_lists(tq_api, partition_id, keys) + + retrieved = tq_api.kv_batch_get( + keys=keys, + partition_id=partition_id, + select_fields="input_ids", + parser=lambda value, length: value[:length], + parser_args=[1, 3, 2], + ) + + assert_nested_tensor_equal( + retrieved["input_ids"], [torch.tensor([0]), torch.tensor([4, 5, 6]), torch.tensor([8, 9])] + ) + + def test_kv_batch_get_parser_out_of_range_index_raises(self, controller, tq_api): + partition_id = "test_partition" + keys = ["parser_oob_0", "parser_oob_1"] + self.put_image_lists(tq_api, partition_id, keys) + + with pytest.raises(RuntimeError, match=r"failed on field 'images'.*indices \[7\] are out of range"): + tq_api.kv_batch_get( + keys=keys, + partition_id=partition_id, + select_fields="images", + parser=tq.select_list_items, + parser_args=[[0], [7]], + ) + + def test_kv_batch_get_parser_exception_reaches_caller(self, controller, tq_api): + partition_id = "test_partition" + keys = ["parser_raise_0"] + self.put_image_lists(tq_api, partition_id, keys) + + with pytest.raises(RuntimeError, match="ZeroDivisionError"): + tq_api.kv_batch_get(keys=keys, partition_id=partition_id, parser=lambda value, arg: 1 / 0) + + def test_kv_batch_get_parser_missing_key_raises(self, controller, tq_api): + partition_id = "test_partition" + keys = ["parser_missing_0"] + self.put_image_lists(tq_api, partition_id, keys) + + with pytest.raises(ValueError, match="only 1 of 2 keys were found"): + tq_api.kv_batch_get( + keys=["parser_missing_0", "parser_missing_absent"], + partition_id=partition_id, + select_fields="images", + parser=tq.select_list_items, + parser_args=[[0], [0]], + ) + + class TestKVBatchGetByMetaE2E: """End-to-end tests for kv_batch_get_by_meta functionality.""" diff --git a/tests/test_client.py b/tests/test_client.py index aa887656..74cfd5d2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -434,7 +434,7 @@ def client_setup(mock_controller, mock_storage): async def mock_put_data(data, metadata, data_parser=None): pass # Just pretend to store the data - async def mock_get_data(metadata): + async def mock_get_data(metadata, **kwargs): # Return the test data when requested return TEST_DATA @@ -527,7 +527,7 @@ def test_single_controller_multiple_storages(): async def mock_put_data(data, metadata, data_parser=None): pass # Just pretend to store the data - async def mock_get_data(metadata): + async def mock_get_data(metadata, **kwargs): # Return some test data when requested return TensorDict({"tokens": torch.randint(0, 100, (5, 128))}, batch_size=5) diff --git a/tests/test_get_parser.py b/tests/test_get_parser.py new file mode 100644 index 00000000..2511e2dc --- /dev/null +++ b/tests/test_get_parser.py @@ -0,0 +1,94 @@ +# Copyright 2026 The TransferQueue Team +# +# Licensed 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 re + +import pytest +import torch +from tensordict import NonTensorData, NonTensorStack + +from transfer_queue.utils.get_parser import apply_get_parser, select_list_items, validate_get_parser + + +def test_select_list_items_keeps_requested_order(): + items = [torch.full((2,), float(i)) for i in range(4)] + + selected = select_list_items(items, [3, 0, 3]) + + assert [item[0].item() for item in selected] == [3.0, 0.0, 3.0] + + +def test_select_list_items_empty_selection_returns_empty_list(): + assert select_list_items([1, 2], []) == [] + + +@pytest.mark.parametrize("indices", [[2], [-1]]) +def test_select_list_items_out_of_range_raises(indices): + with pytest.raises(IndexError, match=re.escape(f"indices {indices} are out of range for a list of 2 items")): + select_list_items([1, 2], indices) + + +def test_select_list_items_non_list_value_raises(): + with pytest.raises(TypeError, match="needs a list or tuple value, got Tensor"): + select_list_items(torch.zeros(3), [0]) + + +def test_validate_get_parser_rejects_args_without_parser(): + with pytest.raises(ValueError, match="parser_args was given without a parser"): + validate_get_parser(None, [[0]], num_keys=1) + + +def test_validate_get_parser_rejects_non_callable(): + with pytest.raises(TypeError, match="parser must be callable, got str"): + validate_get_parser("select_list_items", None, num_keys=1) + + +def test_validate_get_parser_rejects_misaligned_args(): + with pytest.raises(ValueError, match="parser_args has 1 entries but 2 keys are fetched"): + validate_get_parser(select_list_items, [[0]], num_keys=2) + + +def test_apply_get_parser_uses_each_key_arg_on_every_field(): + field_data = {"images": [["a0", "a1", "a2"], ["b0"]], "frames": [["fa0", "fa1", "fa2"], ["fb0", "fb1"]]} + + parsed = apply_get_parser(field_data, select_list_items, [[2, 0], [0]], global_indexes=[5, 9]) + + assert parsed == {"images": [["a2", "a0"], ["b0"]], "frames": [["fa2", "fa0"], ["fb0"]]} + + +def test_apply_get_parser_unwraps_non_tensor_values(): + field_data = {"images": [NonTensorStack("x", "y"), NonTensorData(["z0", "z1"])]} + + parsed = apply_get_parser(field_data, select_list_items, [[1], [0, 1]], global_indexes=[0, 1]) + + assert parsed == {"images": [["y"], ["z0", "z1"]]} + + +def test_apply_get_parser_without_args_passes_none(): + seen_args = [] + + def record_arg(value, parser_arg): + seen_args.append(parser_arg) + return value + + apply_get_parser({"x": [1, 2]}, record_arg, None, global_indexes=[0, 1]) + + assert seen_args == [None, None] + + +def test_apply_get_parser_error_names_field_index_and_arg(): + with pytest.raises(ValueError, match=r"failed on field 'images' of global index 9 with parser_arg \[3\]") as e: + apply_get_parser({"images": [["a0"], ["b0"]]}, select_list_items, [[0], [3]], global_indexes=[5, 9]) + + assert isinstance(e.value.__cause__, IndexError) diff --git a/tests/test_kv_storage_manager.py b/tests/test_kv_storage_manager.py index dacb0be2..01665c99 100644 --- a/tests/test_kv_storage_manager.py +++ b/tests/test_kv_storage_manager.py @@ -516,3 +516,14 @@ def test_put_data_custom_backend_meta_length_mismatch_raises_error(test_data_for asyncio.run(manager.put_data(test_data_for_put_data["data"], test_data_for_put_data["metadata"])) assert "does not match" in str(exc_info.value) + + +@patch.object(KVStorageManager, "_connect_to_controller", lambda self: None) +def test_get_data_with_parser_raises_not_implemented(test_data_for_put_data): + mock_storage_client = MagicMock() + with patch(f"{STORAGE_CLIENT_FACTORY_PATH}.create", return_value=mock_storage_client): + manager = KVStorageManager(controller_info=MagicMock(), config={"client_name": "MockClient"}) + + with pytest.raises(NotImplementedError, match="does not support kv_batch_get parsers"): + asyncio.run(manager.get_data(test_data_for_put_data["metadata"], parser=lambda value, arg: value)) + mock_storage_client.get.assert_not_called() diff --git a/transfer_queue/__init__.py b/transfer_queue/__init__.py index 6c514b2b..3aa8364b 100644 --- a/transfer_queue/__init__.py +++ b/transfer_queue/__init__.py @@ -59,6 +59,7 @@ from .sampler.seqlen_balanced_sampler import SeqlenBalancedSampler from .sampler.sequential_sampler import SequentialSampler from .storage import StorageKeyNotFoundError +from .utils.get_parser import GetParser, select_list_items __all__ = ( [ @@ -83,6 +84,8 @@ "async_kv_clear", "async_kv_update", "KVBatchMeta", + "GetParser", + "select_list_items", ] + [ # KV key locks: process-local and cluster-wide diff --git a/transfer_queue/client.py b/transfer_queue/client.py index 41ff29d4..77ca0950 100644 --- a/transfer_queue/client.py +++ b/transfer_queue/client.py @@ -17,6 +17,7 @@ import os import threading import weakref +from collections.abc import Sequence from typing import Any, Callable import torch @@ -27,6 +28,7 @@ from transfer_queue.metadata import BatchMeta from transfer_queue.storage import StorageManagerFactory from transfer_queue.utils.common import limit_pytorch_auto_parallel_threads +from transfer_queue.utils.get_parser import GetParser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.zmq_utils import ( TQ_SOCKET_POOL_SIZE, @@ -522,11 +524,19 @@ async def async_update( field_schema = await self.storage_manager.update_data(metadata, values, merge_fn) return metadata.apply_field_schema(field_schema) - async def async_get_data(self, metadata: BatchMeta) -> TensorDict: + async def async_get_data( + self, + metadata: BatchMeta, + parser: GetParser | None = None, + parser_args: Sequence[Any] | None = None, + ) -> TensorDict: """Asynchronously fetch data from storage units and organize into TensorDict. Args: metadata: Batch metadata containing data location information and global indexes + parser: Optional get parser the storage units apply to every requested value before replying. + See ``transfer_queue.kv_batch_get``; only supported by SimpleStorage. + parser_args: Optional per-sample parser arguments, aligned with ``metadata.global_indexes``. Returns: TensorDict containing: @@ -560,7 +570,7 @@ async def async_get_data(self, metadata: BatchMeta) -> TensorDict: with limit_pytorch_auto_parallel_threads( target_num_threads=TQ_NUM_THREADS, info=f"[{self.client_id}] async_get_data" ): - results = await self.storage_manager.get_data(metadata) + results = await self.storage_manager.get_data(metadata, parser=parser, parser_args=parser_args) logger.debug(f"[{self.client_id}]: get_data with {metadata.size} samples successfully.") @@ -1645,11 +1655,19 @@ def update( """ return self._update(metadata, values, merge_fn) - def get_data(self, metadata: BatchMeta) -> TensorDict: + def get_data( + self, + metadata: BatchMeta, + parser: GetParser | None = None, + parser_args: Sequence[Any] | None = None, + ) -> TensorDict: """Synchronously fetch data from storage units and organize into TensorDict. Args: metadata: Batch metadata containing data location information and global indexes + parser: Optional get parser the storage units apply to every requested value before replying. + See ``transfer_queue.kv_batch_get``; only supported by SimpleStorage. + parser_args: Optional per-sample parser arguments, aligned with ``metadata.global_indexes``. Returns: TensorDict containing: @@ -1667,7 +1685,7 @@ def get_data(self, metadata: BatchMeta) -> TensorDict: >>> print(batch) >>> # TensorDict with fields "prompts", "attention_mask", and sample order matching metadata global_indexes """ - return self._get_data(metadata=metadata) + return self._get_data(metadata=metadata, parser=parser, parser_args=parser_args) def clear_partition(self, partition_id: str): """Synchronously clear the whole partition from all storage units and the controller. diff --git a/transfer_queue/interface.py b/transfer_queue/interface.py index b3e0ede4..8c71a76b 100644 --- a/transfer_queue/interface.py +++ b/transfer_queue/interface.py @@ -33,6 +33,7 @@ import shutil import subprocess import time +from collections.abc import Sequence from importlib import resources from pathlib import Path from typing import Any, Callable @@ -53,6 +54,7 @@ from transfer_queue.sampler import BaseSampler from transfer_queue.storage.bootstrap import StorageBootstrapProvider from transfer_queue.storage.managers.simple_storage_manager import AsyncSimpleStorageManager +from transfer_queue.utils.get_parser import GetParser, validate_get_parser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.yuanrong_utils import cleanup_yuanrong_resources from transfer_queue.utils.zmq_utils import process_zmq_server_info @@ -523,7 +525,13 @@ def kv_batch_get_by_meta(meta: KVBatchMeta, select_fields: list[str] | str | Non return tq_client._run_coroutine(async_kv_batch_get_by_meta(meta=meta, select_fields=select_fields)) -def kv_batch_get(keys: list[str] | str, partition_id: str, select_fields: list[str] | str | None = None) -> TensorDict: +def kv_batch_get( + keys: list[str] | str, + partition_id: str, + select_fields: list[str] | str | None = None, + parser: GetParser | None = None, + parser_args: Sequence[Any] | None = None, +) -> TensorDict: """Get data from TransferQueue using user-specified keys. This is a convenience method for retrieving data using keys instead of indexes. @@ -532,6 +540,10 @@ def kv_batch_get(keys: list[str] | str, partition_id: str, select_fields: list[s keys: Single key or list of keys to retrieve partition_id: Partition containing the keys select_fields: Optional field(s) to retrieve. If None, retrieves all fields + parser: Optional ``parser(value, parser_arg)`` each SimpleStorage unit applies to every selected (key, field) + before serializing its reply, e.g. ``transfer_queue.select_list_items``. Sent by cloudpickle, so a + module-level parser must be importable on the storage units. KV backends raise NotImplementedError. + parser_args: Optional per-key ``parser_arg`` values aligned with ``keys``; None passes None for every key. Returns: TensorDict with the requested data @@ -539,6 +551,8 @@ def kv_batch_get(keys: list[str] | str, partition_id: str, select_fields: list[s Raises: ValueError: If keys or partition are not found ValueError: If empty fields exist in any key (sample) + ValueError: If a parser is given and some keys are missing + RuntimeError: If the parser fails on a storage unit; the message names the field, global index and parser_arg Example: >>> import transfer_queue as tq @@ -551,13 +565,38 @@ def kv_batch_get(keys: list[str] | str, partition_id: str, select_fields: list[s ... partition_id="train", ... select_fields="input_ids" ... ) + >>> # Get the images at per-key indices out of a list-valued field + >>> data = tq.kv_batch_get( + ... keys=["sample_1", "sample_2"], + ... partition_id="train", + ... select_fields="images", + ... parser=tq.select_list_items, + ... parser_args=[[0, 2], [1]], + ... ) """ + keys = [keys] if isinstance(keys, str) else keys + validate_get_parser(parser, parser_args, len(keys)) tq_client = _maybe_create_tq_client() return tq_client._run_coroutine( - async_kv_batch_get(keys=keys, partition_id=partition_id, select_fields=select_fields) + async_kv_batch_get( + keys=keys, + partition_id=partition_id, + select_fields=select_fields, + parser=parser, + parser_args=parser_args, + ) ) +def _check_all_keys_found(num_found: int, keys: list[str], partition_id: str, parser: GetParser | None) -> None: + # NOTE(neowywang): a missing key is dropped from the meta, which would shift parser_args onto other keys. + if parser is not None and num_found != len(keys): + raise ValueError( + f"only {num_found} of {len(keys)} keys were found in partition {partition_id!r}; a kv_batch_get " + "with a parser needs every key, since parser_args are aligned with keys" + ) + + def kv_list(partition_id: str | None = None) -> dict[str, dict[str, Any]]: """List all keys and their metadata in one or all partitions. @@ -938,7 +977,11 @@ async def async_kv_batch_get_by_meta(meta: KVBatchMeta, select_fields: list[str] async def async_kv_batch_get( - keys: list[str] | str, partition_id: str, select_fields: list[str] | str | None = None + keys: list[str] | str, + partition_id: str, + select_fields: list[str] | str | None = None, + parser: GetParser | None = None, + parser_args: Sequence[Any] | None = None, ) -> TensorDict: """Asynchronously get data from TransferQueue using user-specified keys. @@ -948,6 +991,8 @@ async def async_kv_batch_get( keys: Single key or list of keys to retrieve partition_id: Partition containing the keys select_fields: Optional field(s) to retrieve. If None, retrieves all fields + parser: Optional get parser each SimpleStorage unit applies before replying; see ``kv_batch_get``. + parser_args: Optional per-key ``parser_arg`` values aligned with ``keys``; None passes None for every key. Returns: TensorDict with the requested data @@ -955,6 +1000,8 @@ async def async_kv_batch_get( Raises: ValueError: If keys or partition are not found ValueError: If empty fields exist in any key (sample) + ValueError: If a parser is given and some keys are missing + RuntimeError: If the parser fails on a storage unit; the message names the field, global index and parser_arg Example: >>> import transfer_queue as tq @@ -968,12 +1015,15 @@ async def async_kv_batch_get( ... select_fields="input_ids" ... ) """ + keys = [keys] if isinstance(keys, str) else keys + validate_get_parser(parser, parser_args, len(keys)) tq_client = _maybe_create_tq_client() batch_meta = await tq_client.async_kv_retrieve_meta(keys=keys, partition_id=partition_id, create=False) if batch_meta.size == 0: raise ValueError("keys or partition were not found!") + _check_all_keys_found(batch_meta.size, keys, partition_id, parser) if select_fields is not None: if isinstance(select_fields, str): @@ -985,7 +1035,7 @@ async def async_kv_batch_get( if not batch_meta.is_ready: raise ValueError("Some fields are not ready in all the requested keys!") - data = await tq_client.async_get_data(batch_meta) + data = await tq_client.async_get_data(batch_meta, parser=parser, parser_args=parser_args) return data diff --git a/transfer_queue/storage/managers/base.py b/transfer_queue/storage/managers/base.py index 1015172e..4f37c78f 100644 --- a/transfer_queue/storage/managers/base.py +++ b/transfer_queue/storage/managers/base.py @@ -21,6 +21,7 @@ import time import weakref from abc import ABC, abstractmethod +from collections.abc import Sequence from concurrent.futures import ThreadPoolExecutor from typing import Any, Callable from uuid import uuid4 @@ -35,6 +36,7 @@ from transfer_queue.metadata import BatchMeta, extract_field_schema from transfer_queue.storage.clients.base import StorageClientFactory +from transfer_queue.utils.get_parser import GetParser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.zmq_utils import ( STORAGE_MANAGER_IDENTITY_PREFIX, @@ -334,12 +336,19 @@ async def put_data( raise NotImplementedError("Subclasses must implement put_data") @abstractmethod - async def get_data(self, metadata: BatchMeta) -> TensorDict: + async def get_data( + self, + metadata: BatchMeta, + parser: GetParser | None = None, + parser_args: Sequence[Any] | None = None, + ) -> TensorDict: """ Get data from the storage backend. Args: metadata: BatchMeta of the data to be retrieved from the storage. + parser: Optional get parser applied to every requested value where it is stored, before sending. + parser_args: Optional per-sample parser arguments, aligned with ``metadata.global_indexes``. Returns: TensorDict containing the data retrieved from the storage. @@ -823,13 +832,24 @@ async def put_data( per_field_custom_backend_meta, ) - async def get_data(self, metadata: BatchMeta) -> TensorDict: + async def get_data( + self, + metadata: BatchMeta, + parser: GetParser | None = None, + parser_args: Sequence[Any] | None = None, + ) -> TensorDict: """ Retrieve tensor data from the backend storage. Fetches tensors using the provided metadata, reconstructs them with the correct shapes and dtypes, and merge them as a TensorDict according to metadata. """ + if parser is not None: + raise NotImplementedError( + f"{type(self).__name__} does not support kv_batch_get parsers: KV backends return whole (key, field) " + "values and cannot run a parser where the data is stored. Use the SimpleStorage backend, or fetch " + "without parser= and select the needed part on the caller side" + ) if not metadata.field_names: logger.warning("Attempted to get data, but metadata contains no fields.") return TensorDict({}, batch_size=len(metadata)) diff --git a/transfer_queue/storage/managers/simple_storage_manager.py b/transfer_queue/storage/managers/simple_storage_manager.py index b5bf88b2..175c83db 100644 --- a/transfer_queue/storage/managers/simple_storage_manager.py +++ b/transfer_queue/storage/managers/simple_storage_manager.py @@ -18,7 +18,7 @@ import time import warnings from collections import defaultdict -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from functools import partial from operator import itemgetter from pathlib import Path @@ -36,6 +36,7 @@ StorageKeyNotFoundError, ) from transfer_queue.utils.common import log_heavy_operation +from transfer_queue.utils.get_parser import GetParser, validate_get_parser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.zmq_utils import ( TQ_SOCKET_POOL_SIZE, @@ -779,7 +780,12 @@ def _pack_field_values(values: list) -> torch.Tensor | NonTensorStack: # Mixed tensor + None — cannot create nested tensor, fall through to NonTensorStack return NonTensorStack(*values) - async def get_data(self, metadata: BatchMeta) -> TensorDict: + async def get_data( + self, + metadata: BatchMeta, + parser: GetParser | None = None, + parser_args: Sequence[Any] | None = None, + ) -> TensorDict: """ Retrieve data from remote StorageUnit based on metadata. @@ -787,6 +793,8 @@ async def get_data(self, metadata: BatchMeta) -> TensorDict: Args: metadata: BatchMeta that contains metadata for data retrieval. + parser: Optional ``parser(value, parser_arg)`` each SU applies to every requested value before replying. + parser_args: Optional per-sample parser arguments, aligned with ``metadata.global_indexes``. Returns: TensorDict containing the retrieved data. @@ -796,6 +804,7 @@ async def get_data(self, metadata: BatchMeta) -> TensorDict: if metadata.size == 0: return TensorDict({}, batch_size=0) + validate_get_parser(parser, parser_args, metadata.size) routing = self._group_by_hash(metadata.global_indexes) @@ -809,6 +818,8 @@ async def get_data(self, metadata: BatchMeta) -> TensorDict: group.global_indexes, metadata.field_names, target_storage_unit=su_id, + parser=parser, + parser_args=None if parser_args is None else [parser_args[pos] for pos in group.batch_positions], ), ) for su_id, group in routing.items() @@ -847,14 +858,19 @@ async def _get_from_single_storage_unit( global_indexes: list[int], fields: list[str], target_storage_unit: str, + parser: GetParser | None = None, + parser_args: list[Any] | None = None, socket: zmq.Socket = None, ): """Get data from a single SU by global index keys.""" + body = {"global_indexes": global_indexes, "fields": fields} + if parser is not None: + body.update(parser=parser, parser_args=parser_args) request_msg = ZMQMessage.create( request_type=ZMQRequestType.GET_DATA, # type: ignore[arg-type] sender_id=self.storage_manager_id, receiver_id=target_storage_unit, - body={"global_indexes": global_indexes, "fields": fields}, + body=body, ) try: await socket.send_multipart(request_msg.serialize()) @@ -862,6 +878,11 @@ async def _get_from_single_storage_unit( response_msg = ZMQMessage.deserialize(messages) if response_msg.request_type == ZMQRequestType.GET_DATA_RESPONSE: + if parser is not None and not response_msg.body.get("parsed", False): + raise RuntimeError( + f"storage unit {target_storage_unit} replied without applying the kv_batch_get parser; it " + "runs a TransferQueue older than get parsers, so restart the storage units on this version" + ) storage_unit_data = response_msg.body["data"] return fields, storage_unit_data else: diff --git a/transfer_queue/storage/simple_storage.py b/transfer_queue/storage/simple_storage.py index ca8ad853..14ab8b94 100644 --- a/transfer_queue/storage/simple_storage.py +++ b/transfer_queue/storage/simple_storage.py @@ -51,6 +51,7 @@ from transfer_queue.utils import compact_pickle from transfer_queue.utils.common import limit_pytorch_auto_parallel_threads, log_heavy_operation from transfer_queue.utils.enum_utils import Role +from transfer_queue.utils.get_parser import apply_get_parser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.perf_utils import IntervalPerfMonitor from transfer_queue.utils.zmq_utils import ( @@ -1289,18 +1290,25 @@ def _handle_get(self, data_parts: ZMQMessage) -> ZMQMessage: try: fields = data_parts.body["fields"] global_indexes = data_parts.body["global_indexes"] + parser = data_parts.body.get("parser") with limit_pytorch_auto_parallel_threads( target_num_threads=TQ_NUM_THREADS, info=f"[{self.storage_unit_id}] _handle_get" ): result_data = self.storage_data.get_data(fields, global_indexes) + if parser is not None: + result_data = apply_get_parser( + result_data, parser, data_parts.body.get("parser_args"), global_indexes + ) + response_body: dict[str, Any] = {"data": result_data} + if parser is not None: + # NOTE(neowywang): lets the manager tell a parsed reply from an older unit that ignored the parser. + response_body["parsed"] = True response_msg = ZMQMessage.create( request_type=ZMQRequestType.GET_DATA_RESPONSE, # type: ignore[arg-type] sender_id=self.storage_unit_id, - body={ - "data": result_data, - }, + body=response_body, ) except Exception as e: key_not_found = isinstance(e, StorageKeyNotFoundError) diff --git a/transfer_queue/utils/get_parser.py b/transfer_queue/utils/get_parser.py new file mode 100644 index 00000000..d280087c --- /dev/null +++ b/transfer_queue/utils/get_parser.py @@ -0,0 +1,89 @@ +# Copyright 2026 The TransferQueue Team +# +# Licensed 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. + +"""Get parsers: callables a SimpleStorage unit applies to stored values before replying to ``kv_batch_get``. + +A parser trims a (key, field) value to the part the caller needs, so only that part is serialized and sent.""" + +import reprlib +from collections.abc import Callable, Sequence +from typing import Any + +from tensordict import NonTensorData, NonTensorStack + +GetParser = Callable[[Any, Any], Any] +"""``parser(value, parser_arg) -> parsed value``, called once per selected (key, field).""" + + +def select_list_items(value: Sequence[Any], indices: Sequence[int]) -> list[Any]: + """Return the items of a list-valued field at ``indices``, in that order, e.g. some images of an image list. + + An index outside ``[0, len(value))`` raises ``IndexError`` instead of wrapping around.""" + if not isinstance(value, list | tuple): + raise TypeError(f"select_list_items needs a list or tuple value, got {type(value).__name__}") + out_of_range = [index for index in indices if not 0 <= index < len(value)] + if out_of_range: + raise IndexError(f"indices {out_of_range} are out of range for a list of {len(value)} items") + return [value[index] for index in indices] + + +def validate_get_parser(parser: GetParser | None, parser_args: Sequence[Any] | None, num_keys: int) -> None: + """Check a ``kv_batch_get`` parser request before anything is sent to the storage units.""" + if parser is None: + if parser_args is not None: + raise ValueError("parser_args was given without a parser; pass parser= as well or drop parser_args") + return + if not callable(parser): + raise TypeError(f"parser must be callable, got {type(parser).__name__}") + if parser_args is not None and len(parser_args) != num_keys: + raise ValueError( + f"parser_args has {len(parser_args)} entries but {num_keys} keys are fetched; give one per key" + ) + + +def apply_get_parser( + field_data: dict[str, list[Any]], + parser: GetParser, + parser_args: Sequence[Any] | None, + global_indexes: Sequence[int], +) -> dict[str, list[Any]]: + """Replace every stored value by ``parser(value, parser_arg)`` of its key; values arrive unwrapped from NonTensor*. + + A parser error is re-raised as ``ValueError`` naming the field, global index and parser_arg it failed on.""" + if parser_args is None: + parser_args = [None] * len(global_indexes) + elif len(parser_args) != len(global_indexes): + raise ValueError(f"got {len(parser_args)} parser_args for {len(global_indexes)} global indexes") + parser_name = getattr(parser, "__qualname__", type(parser).__name__) + parsed: dict[str, list[Any]] = {} + for field, values in field_data.items(): + column = [] + for value, parser_arg, global_index in zip(values, parser_args, global_indexes, strict=True): + try: + column.append(parser(_unwrap_non_tensor(value), parser_arg)) + except Exception as e: + raise ValueError( + f"get parser {parser_name} failed on field {field!r} of global index {global_index} " + f"with parser_arg {reprlib.repr(parser_arg)}: {type(e).__name__}: {e}" + ) from e + parsed[field] = column + return parsed + + +def _unwrap_non_tensor(value: Any) -> Any: + if isinstance(value, NonTensorStack): + return value.tolist() + if isinstance(value, NonTensorData): + return value.data + return value From 23bf05da0f0bf3b21434d6e7eac7726de6c216a4 Mon Sep 17 00:00:00 2001 From: OutstanderWang Date: Fri, 9 Oct 2026 21:15:39 +0800 Subject: [PATCH 02/14] test: cover kv_batch_get parser on rows mixing video frames and images (cherry picked from commit 93e9bdf44784e82b8bb32fde2d1f1e045e60cca0) Signed-off-by: OutstanderWang --- tests/e2e/test_kv_interface_e2e.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/e2e/test_kv_interface_e2e.py b/tests/e2e/test_kv_interface_e2e.py index cc49472d..2bc4e574 100644 --- a/tests/e2e/test_kv_interface_e2e.py +++ b/tests/e2e/test_kv_interface_e2e.py @@ -771,6 +771,34 @@ def test_kv_batch_get_parser_selects_list_items_per_key(self, controller, tq_api full = tq_api.kv_batch_get(keys="parser_3", partition_id=partition_id, select_fields="images") assert len(full["images"][0]) == len(image_lists[3]) + def test_kv_batch_get_parser_selects_video_frames_mixed_with_images(self, controller, tq_api): + partition_id = "test_partition" + keys = ["parser_video_0", "parser_video_1"] + # Each row is image, three same-shaped video frames, image, as a VLM row stores them. + rows = [ + [torch.full((3, 4, 8), float(10 * k))] + + [torch.full((3, 4, 4), float(10 * k + f + 1)) for f in range(3)] + + [torch.full((3, 8, 4), float(10 * k + 9))] + for k in range(len(keys)) + ] + fields = TensorDict({"images": NonTensorStack(*rows)}, batch_size=len(keys)) + tq_api.kv_batch_put(keys=keys, partition_id=partition_id, fields=fields, tags=[{} for _ in keys]) + + for parser_args in ([[1], [2]], [[1, 2, 3], [3, 1, 2]], [[0, 2, 4], [1, 3]]): + retrieved = tq_api.kv_batch_get( + keys=keys, + partition_id=partition_id, + select_fields="images", + parser=tq.select_list_items, + parser_args=parser_args, + ) + got_rows = list(retrieved["images"]) + assert len(got_rows) == len(rows) + for got_row, row, item_indices in zip(got_rows, rows, parser_args, strict=True): + assert isinstance(got_row, list) and len(got_row) == len(item_indices) + for got, item_idx in zip(got_row, item_indices, strict=True): + assert_tensor_equal(got, row[item_idx]) + def test_kv_batch_get_parser_empty_selection(self, controller, tq_api): partition_id = "test_partition" keys = ["parser_empty_0", "parser_empty_1"] From 5e340562ea2cdb6ed12e4641aa0b5c4f995311f4 Mon Sep 17 00:00:00 2001 From: OutstanderWang Date: Fri, 9 Oct 2026 22:05:55 +0800 Subject: [PATCH 03/14] docs+test: clarify kv_batch_get parser scope and cover manager parser routing - kv_batch_get docs: the parser runs on every selected field, synchronously on the storage unit worker; pair it with select_fields and keep it light. - apply_get_parser docs: kv_batch_get callers see parser errors as RuntimeError. - Manager tests: parser_args are sliced per storage unit by batch position, and a reply without the parsed flag is rejected. (cherry picked from commit 47de4213780b4a07fab6fa4561859040badb78f1) Signed-off-by: OutstanderWang --- tests/test_async_simple_storage_manager.py | 68 ++++++++++++++++++++++ transfer_queue/interface.py | 11 ++-- transfer_queue/utils/get_parser.py | 2 +- 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/tests/test_async_simple_storage_manager.py b/tests/test_async_simple_storage_manager.py index a6875600..61864497 100644 --- a/tests/test_async_simple_storage_manager.py +++ b/tests/test_async_simple_storage_manager.py @@ -25,6 +25,7 @@ from transfer_queue.metadata import BatchMeta from transfer_queue.storage import AsyncSimpleStorageManager from transfer_queue.utils.enum_utils import Role +from transfer_queue.utils.get_parser import select_list_items from transfer_queue.utils.zmq_utils import ZMQMessage, ZMQRequestType, ZMQServerInfo @@ -576,3 +577,70 @@ def test_all_none_to_nontensorstack(self): assert isinstance(result, NonTensorStack) assert result.tolist() == [None, None] + + +def _two_unit_manager(storage_manager_id: str) -> AsyncSimpleStorageManager: + manager = AsyncSimpleStorageManager.__new__(AsyncSimpleStorageManager) + manager.storage_manager_id = storage_manager_id + manager.storage_unit_infos = { + unit_id: ZMQServerInfo(role=Role.STORAGE, id=unit_id, ip="127.0.0.1", ports={"put_get_socket": port}) + for unit_id, port in (("storage_0", 19050), ("storage_1", 19051)) + } + return manager + + +@pytest.mark.asyncio +async def test_get_data_slices_parser_args_per_storage_unit_by_batch_position(): + manager = _two_unit_manager("test_parser_routing") + # global_index % 2 routes 2 and 0 to storage_0, 3 and 1 to storage_1. + batch_meta = BatchMeta( + global_indexes=[3, 0, 2, 1], + partition_ids=["p0"] * 4, + field_schema={"images": {"dtype": None, "shape": None, "is_nested": False, "is_non_tensor": True}}, + production_status=np.ones(4, dtype=np.int8), + ) + parser_args = [["arg_3"], ["arg_0"], ["arg_2"], ["arg_1"]] + requests: dict[str, dict] = {} + + async def fake_get(global_indexes, fields, target_storage_unit=None, parser=None, parser_args=None): + requests[target_storage_unit] = {"global_indexes": global_indexes, "parser": parser, "args": parser_args} + values = [f"value_{gi}_{args[0]}" for gi, args in zip(global_indexes, parser_args, strict=True)] + return fields, {"images": values} + + manager._get_from_single_storage_unit = fake_get + + data = await manager.get_data(batch_meta, parser=select_list_items, parser_args=parser_args) + + assert requests == { + "storage_0": {"global_indexes": [0, 2], "parser": select_list_items, "args": [["arg_0"], ["arg_2"]]}, + "storage_1": {"global_indexes": [3, 1], "parser": select_list_items, "args": [["arg_3"], ["arg_1"]]}, + } + assert list(data["images"]) == ["value_3_arg_3", "value_0_arg_0", "value_2_arg_2", "value_1_arg_1"] + + +@pytest.mark.asyncio +async def test_get_with_parser_rejects_reply_without_parsed_flag(): + manager = _two_unit_manager("test_parser_old_unit") + old_unit_reply = ZMQMessage.create( + request_type=ZMQRequestType.GET_DATA_RESPONSE, # type: ignore[arg-type] + sender_id="storage_0", + body={"data": {"images": [["whole_row"]]}}, + ) + socket = Mock() + socket.send_multipart = AsyncMock() + socket.recv_multipart = AsyncMock(return_value=old_unit_reply.serialize()) + get_from_unit = AsyncSimpleStorageManager._get_from_single_storage_unit.__wrapped__ + + with pytest.raises(RuntimeError, match="storage unit storage_0 replied without applying the kv_batch_get parser"): + await get_from_unit( + manager, + [0], + ["images"], + target_storage_unit="storage_0", + parser=select_list_items, + parser_args=[[0]], + socket=socket, + ) + + sent_body = ZMQMessage.deserialize(socket.send_multipart.await_args.args[0]).body + assert sent_body["parser"] is select_list_items and sent_body["parser_args"] == [[0]] diff --git a/transfer_queue/interface.py b/transfer_queue/interface.py index 8c71a76b..60781961 100644 --- a/transfer_queue/interface.py +++ b/transfer_queue/interface.py @@ -541,8 +541,9 @@ def kv_batch_get( partition_id: Partition containing the keys select_fields: Optional field(s) to retrieve. If None, retrieves all fields parser: Optional ``parser(value, parser_arg)`` each SimpleStorage unit applies to every selected (key, field) - before serializing its reply, e.g. ``transfer_queue.select_list_items``. Sent by cloudpickle, so a - module-level parser must be importable on the storage units. KV backends raise NotImplementedError. + before replying, e.g. ``transfer_queue.select_list_items``; pair it with ``select_fields``. It runs + synchronously on the storage unit worker, so keep it light and side-effect free. Sent by cloudpickle, so + a module-level parser must be importable on the storage units. KV backends raise NotImplementedError. parser_args: Optional per-key ``parser_arg`` values aligned with ``keys``; None passes None for every key. Returns: @@ -565,7 +566,8 @@ def kv_batch_get( ... partition_id="train", ... select_fields="input_ids" ... ) - >>> # Get the images at per-key indices out of a list-valued field + >>> # Get the images at per-key indices out of a list-valued field; select_fields keeps the parser off + >>> # fields such as input_ids, which select_list_items would reject >>> data = tq.kv_batch_get( ... keys=["sample_1", "sample_2"], ... partition_id="train", @@ -991,7 +993,8 @@ async def async_kv_batch_get( keys: Single key or list of keys to retrieve partition_id: Partition containing the keys select_fields: Optional field(s) to retrieve. If None, retrieves all fields - parser: Optional get parser each SimpleStorage unit applies before replying; see ``kv_batch_get``. + parser: Optional get parser each SimpleStorage unit applies to every selected (key, field) before replying; + pair it with ``select_fields`` and keep it light and side-effect free. See ``kv_batch_get``. parser_args: Optional per-key ``parser_arg`` values aligned with ``keys``; None passes None for every key. Returns: diff --git a/transfer_queue/utils/get_parser.py b/transfer_queue/utils/get_parser.py index d280087c..8b09d1b6 100644 --- a/transfer_queue/utils/get_parser.py +++ b/transfer_queue/utils/get_parser.py @@ -60,7 +60,7 @@ def apply_get_parser( ) -> dict[str, list[Any]]: """Replace every stored value by ``parser(value, parser_arg)`` of its key; values arrive unwrapped from NonTensor*. - A parser error is re-raised as ``ValueError`` naming the field, global index and parser_arg it failed on.""" + Parser errors raise ``ValueError`` (field, global index, arg); kv_batch_get callers get ``RuntimeError``.""" if parser_args is None: parser_args = [None] * len(global_indexes) elif len(parser_args) != len(global_indexes): From 6e35c3fb0a6cdc2611dd727b8077df3d54696a99 Mon Sep 17 00:00:00 2001 From: OutstanderWang Date: Mon, 12 Oct 2026 17:19:12 +0800 Subject: [PATCH 04/14] [feat] Add slice_tensor get parser for zero-copy tensor field trims Let kv_batch_get return a contiguous view of a stored tensor so only the sliced bytes are serialized on the storage unit, matching select_list_items for list fields. Signed-off-by: OutstanderWang --- tests/e2e/test_kv_interface_e2e.py | 2 +- tests/test_get_parser.py | 75 +++++++++++++++++++++++++++++- transfer_queue/__init__.py | 3 +- transfer_queue/interface.py | 8 ++++ transfer_queue/utils/get_parser.py | 28 +++++++++++ 5 files changed, 113 insertions(+), 3 deletions(-) diff --git a/tests/e2e/test_kv_interface_e2e.py b/tests/e2e/test_kv_interface_e2e.py index 2bc4e574..69f00e5f 100644 --- a/tests/e2e/test_kv_interface_e2e.py +++ b/tests/e2e/test_kv_interface_e2e.py @@ -823,7 +823,7 @@ def test_kv_batch_get_parser_trims_tensor_field_to_variable_lengths(self, contro keys=keys, partition_id=partition_id, select_fields="input_ids", - parser=lambda value, length: value[:length], + parser=tq.slice_tensor, parser_args=[1, 3, 2], ) diff --git a/tests/test_get_parser.py b/tests/test_get_parser.py index 2511e2dc..330b5395 100644 --- a/tests/test_get_parser.py +++ b/tests/test_get_parser.py @@ -18,7 +18,7 @@ import torch from tensordict import NonTensorData, NonTensorStack -from transfer_queue.utils.get_parser import apply_get_parser, select_list_items, validate_get_parser +from transfer_queue.utils.get_parser import apply_get_parser, select_list_items, slice_tensor, validate_get_parser def test_select_list_items_keeps_requested_order(): @@ -44,6 +44,79 @@ def test_select_list_items_non_list_value_raises(): select_list_items(torch.zeros(3), [0]) +def test_slice_tensor_prefix_returns_contiguous_view(): + value = torch.arange(12).view(3, 4) + + sliced = slice_tensor(value, 2) + + assert torch.equal(sliced, value[:2]) + assert sliced.data_ptr() == value.data_ptr() + assert sliced.untyped_storage().data_ptr() == value.untyped_storage().data_ptr() + assert sliced.is_contiguous() + + +def test_slice_tensor_accepts_slice_and_tuple_indexes(): + value = torch.arange(24).view(4, 3, 2) + + assert torch.equal(slice_tensor(value, slice(1, 3)), value[1:3]) + assert torch.equal(slice_tensor(value, (slice(None), slice(0, 2))), value[:, :2]) + assert slice_tensor(value, (1, slice(None))).data_ptr() == value[1].data_ptr() + + +def test_slice_tensor_empty_prefix_is_valid_view(): + value = torch.arange(6).view(3, 2) + + sliced = slice_tensor(value, 0) + + assert sliced.shape == (0, 2) + assert sliced.untyped_storage().data_ptr() == value.untyped_storage().data_ptr() + + +@pytest.mark.parametrize("index", [-1, 4]) +def test_slice_tensor_prefix_out_of_range_raises(index): + with pytest.raises(IndexError, match=re.escape(f"prefix length {index} is out of range for dim 0 of size 3")): + slice_tensor(torch.zeros(3, 2), index) + + +def test_slice_tensor_rejects_non_tensor_and_nested(): + with pytest.raises(TypeError, match="needs a torch.Tensor value, got list"): + slice_tensor([1, 2, 3], 1) + nested = torch.nested.as_nested_tensor([torch.ones(2), torch.ones(3)], layout=torch.jagged) + with pytest.raises(TypeError, match="does not support nested tensors"): + slice_tensor(nested, 1) + + +def test_slice_tensor_rejects_bool_and_bad_index_type(): + value = torch.zeros(3) + with pytest.raises(TypeError, match="not bool"): + slice_tensor(value, True) + with pytest.raises(TypeError, match="got list"): + slice_tensor(value, [0, 1]) + + +def test_apply_get_parser_slice_tensor_keeps_per_key_views(): + field_data = {"input_ids": [torch.arange(4), torch.arange(10, 14)]} + + parsed = apply_get_parser(field_data, slice_tensor, [2, 3], global_indexes=[0, 1]) + + assert torch.equal(parsed["input_ids"][0], torch.tensor([0, 1])) + assert torch.equal(parsed["input_ids"][1], torch.tensor([10, 11, 12])) + assert parsed["input_ids"][0].data_ptr() == field_data["input_ids"][0].data_ptr() + assert parsed["input_ids"][1].data_ptr() == field_data["input_ids"][1].data_ptr() + + +def test_slice_tensor_encode_sends_only_sliced_bytes_without_copy(): + from transfer_queue.utils.serial_utils import MsgpackDecoder, MsgpackEncoder + + value = torch.arange(12, dtype=torch.int64).view(3, 4) + sliced = slice_tensor(value, 2) + buffers = MsgpackEncoder().encode(sliced) + + assert buffers[1].nbytes == sliced.nbytes + assert buffers[1].nbytes < value.nbytes + assert torch.equal(MsgpackDecoder().decode(buffers), sliced) + + def test_validate_get_parser_rejects_args_without_parser(): with pytest.raises(ValueError, match="parser_args was given without a parser"): validate_get_parser(None, [[0]], num_keys=1) diff --git a/transfer_queue/__init__.py b/transfer_queue/__init__.py index 3aa8364b..3554c9df 100644 --- a/transfer_queue/__init__.py +++ b/transfer_queue/__init__.py @@ -59,7 +59,7 @@ from .sampler.seqlen_balanced_sampler import SeqlenBalancedSampler from .sampler.sequential_sampler import SequentialSampler from .storage import StorageKeyNotFoundError -from .utils.get_parser import GetParser, select_list_items +from .utils.get_parser import GetParser, select_list_items, slice_tensor __all__ = ( [ @@ -86,6 +86,7 @@ "KVBatchMeta", "GetParser", "select_list_items", + "slice_tensor", ] + [ # KV key locks: process-local and cluster-wide diff --git a/transfer_queue/interface.py b/transfer_queue/interface.py index 60781961..757233a9 100644 --- a/transfer_queue/interface.py +++ b/transfer_queue/interface.py @@ -575,6 +575,14 @@ def kv_batch_get( ... parser=tq.select_list_items, ... parser_args=[[0, 2], [1]], ... ) + >>> # Trim a padded tensor field to each key's real length as a view (only the slice is sent) + >>> data = tq.kv_batch_get( + ... keys=["sample_1", "sample_2"], + ... partition_id="train", + ... select_fields="input_ids", + ... parser=tq.slice_tensor, + ... parser_args=[3, 5], + ... ) """ keys = [keys] if isinstance(keys, str) else keys validate_get_parser(parser, parser_args, len(keys)) diff --git a/transfer_queue/utils/get_parser.py b/transfer_queue/utils/get_parser.py index 8b09d1b6..84350bd6 100644 --- a/transfer_queue/utils/get_parser.py +++ b/transfer_queue/utils/get_parser.py @@ -20,6 +20,7 @@ from collections.abc import Callable, Sequence from typing import Any +import torch from tensordict import NonTensorData, NonTensorStack GetParser = Callable[[Any, Any], Any] @@ -38,6 +39,33 @@ def select_list_items(value: Sequence[Any], indices: Sequence[int]) -> list[Any] return [value[index] for index in indices] +def slice_tensor(value: torch.Tensor, index: int | slice | tuple[Any, ...]) -> torch.Tensor: + """Return a view of a tensor field sliced by ``index``, so only that slice is serialized on get. + + An ``int`` is a dim-0 prefix length (``value[:index]``); a ``slice`` or ``tuple`` is applied as + ``value[index]``. A contiguous leading-dim prefix of a C-contiguous CPU tensor stays a + contiguous view, so the get encoder can extract its buffer without copying. Non-contiguous + results still work, but encoding will materialize them. + """ + if not isinstance(value, torch.Tensor): + raise TypeError(f"slice_tensor needs a torch.Tensor value, got {type(value).__name__}") + if value.is_nested: + raise TypeError("slice_tensor does not support nested tensors; pass a regular tensor field") + if isinstance(index, bool): + raise TypeError("slice_tensor index must be an int stop, a slice, or a tuple of slices/ints, not bool") + if isinstance(index, int): + if value.dim() == 0: + raise IndexError("slice_tensor cannot take a prefix length of a 0-dim tensor") + if not 0 <= index <= value.size(0): + raise IndexError(f"prefix length {index} is out of range for dim 0 of size {value.size(0)}") + return value[:index] + if isinstance(index, slice | tuple): + return value[index] + raise TypeError( + f"slice_tensor index must be an int stop, a slice, or a tuple of slices/ints, got {type(index).__name__}" + ) + + def validate_get_parser(parser: GetParser | None, parser_args: Sequence[Any] | None, num_keys: int) -> None: """Check a ``kv_batch_get`` parser request before anything is sent to the storage units.""" if parser is None: From 0058571ee368e8c8b0a3bb43fcd832c99e027ada Mon Sep 17 00:00:00 2001 From: OutstanderWang Date: Tue, 13 Oct 2026 20:52:37 +0800 Subject: [PATCH 05/14] docs+fix: harden get_parser docs, coerce, and select_fields early-fail Document contiguous encode vs SSD I/O honestly; coerce slice_tensor length args only; fail early when a parser omits select_fields on multi-field keys; clarify list vs tensor index asymmetry with regressions. Signed-off-by: OutstanderWang --- tests/e2e/test_kv_interface_e2e.py | 20 ++- tests/test_get_parser.py | 129 +++++++++++++++- transfer_queue/interface.py | 44 ++++-- .../managers/simple_storage_manager.py | 5 +- transfer_queue/storage/simple_storage.py | 1 + transfer_queue/utils/get_parser.py | 143 ++++++++++++++++-- 6 files changed, 308 insertions(+), 34 deletions(-) diff --git a/tests/e2e/test_kv_interface_e2e.py b/tests/e2e/test_kv_interface_e2e.py index 69f00e5f..1c882ef5 100644 --- a/tests/e2e/test_kv_interface_e2e.py +++ b/tests/e2e/test_kv_interface_e2e.py @@ -851,7 +851,25 @@ def test_kv_batch_get_parser_exception_reaches_caller(self, controller, tq_api): self.put_image_lists(tq_api, partition_id, keys) with pytest.raises(RuntimeError, match="ZeroDivisionError"): - tq_api.kv_batch_get(keys=keys, partition_id=partition_id, parser=lambda value, arg: 1 / 0) + tq_api.kv_batch_get( + keys=keys, + partition_id=partition_id, + select_fields="images", + parser=lambda value, arg: 1 / 0, + ) + + def test_kv_batch_get_parser_requires_select_fields_for_multi_field(self, controller, tq_api): + partition_id = "test_partition" + keys = ["parser_multi_0"] + self.put_image_lists(tq_api, partition_id, keys) + + with pytest.raises(ValueError, match="omits select_fields but the keys have multiple fields"): + tq_api.kv_batch_get( + keys=keys, + partition_id=partition_id, + parser=tq.select_list_items, + parser_args=[[0]], + ) def test_kv_batch_get_parser_missing_key_raises(self, controller, tq_api): partition_id = "test_partition" diff --git a/tests/test_get_parser.py b/tests/test_get_parser.py index 330b5395..8ec6ecbf 100644 --- a/tests/test_get_parser.py +++ b/tests/test_get_parser.py @@ -14,11 +14,18 @@ import re +import numpy as np import pytest import torch from tensordict import NonTensorData, NonTensorStack -from transfer_queue.utils.get_parser import apply_get_parser, select_list_items, slice_tensor, validate_get_parser +from transfer_queue.utils.get_parser import ( + apply_get_parser, + check_parser_select_fields, + select_list_items, + slice_tensor, + validate_get_parser, +) def test_select_list_items_keeps_requested_order(): @@ -90,8 +97,15 @@ def test_slice_tensor_rejects_bool_and_bad_index_type(): value = torch.zeros(3) with pytest.raises(TypeError, match="not bool"): slice_tensor(value, True) - with pytest.raises(TypeError, match="got list"): - slice_tensor(value, [0, 1]) + with pytest.raises(TypeError, match="got str"): + slice_tensor(value, "0:2") + + +def test_slice_tensor_rejects_index_list_unlike_select_list_items(): + """slice_tensor is not advanced indexing; a list of indices is select_list_items-only.""" + value = torch.arange(6).view(3, 2) + with pytest.raises(TypeError, match="does not take a list of indices"): + slice_tensor(value, [0, 2]) def test_apply_get_parser_slice_tensor_keeps_per_key_views(): @@ -105,11 +119,12 @@ def test_apply_get_parser_slice_tensor_keeps_per_key_views(): assert parsed["input_ids"][1].data_ptr() == field_data["input_ids"][1].data_ptr() -def test_slice_tensor_encode_sends_only_sliced_bytes_without_copy(): +def test_slice_tensor_prefix_encode_sends_only_sliced_nbytes(): from transfer_queue.utils.serial_utils import MsgpackDecoder, MsgpackEncoder value = torch.arange(12, dtype=torch.int64).view(3, 4) sliced = slice_tensor(value, 2) + assert sliced.is_contiguous() buffers = MsgpackEncoder().encode(sliced) assert buffers[1].nbytes == sliced.nbytes @@ -117,6 +132,29 @@ def test_slice_tensor_encode_sends_only_sliced_bytes_without_copy(): assert torch.equal(MsgpackDecoder().decode(buffers), sliced) +def test_slice_tensor_noncontiguous_slice_encodes_correct_values(): + """Column / stride slices are correct; MsgpackEncoder may .contiguous() them first.""" + from transfer_queue.utils.serial_utils import MsgpackDecoder, MsgpackEncoder + + value = torch.arange(12, dtype=torch.int64).view(3, 4) + for index in ((slice(None), slice(0, 2)), slice(None, None, 2)): + sliced = slice_tensor(value, index) + assert not sliced.is_contiguous() + assert torch.equal(MsgpackDecoder().decode(MsgpackEncoder().encode(sliced)), sliced) + + +def test_check_parser_select_fields_rejects_multi_field_without_select_fields(): + with pytest.raises(ValueError, match="omits select_fields but the keys have multiple fields"): + check_parser_select_fields(select_list_items, None, ["images", "input_ids"]) + + +def test_check_parser_select_fields_allows_select_fields_or_single_field(): + check_parser_select_fields(select_list_items, "images", ["images", "input_ids"]) + check_parser_select_fields(select_list_items, ["images"], ["images", "input_ids"]) + check_parser_select_fields(select_list_items, None, ["images"]) + check_parser_select_fields(None, None, ["images", "input_ids"]) + + def test_validate_get_parser_rejects_args_without_parser(): with pytest.raises(ValueError, match="parser_args was given without a parser"): validate_get_parser(None, [[0]], num_keys=1) @@ -132,6 +170,89 @@ def test_validate_get_parser_rejects_misaligned_args(): validate_get_parser(select_list_items, [[0]], num_keys=2) +def test_validate_get_parser_keeps_plain_int_list_and_slice_args(): + ints = [2, 3] + assert validate_get_parser(slice_tensor, ints, num_keys=2) is ints + slices = [slice(0, 2), (slice(None), slice(0, 1))] + assert validate_get_parser(slice_tensor, slices, num_keys=2) is slices + nested = [[0, 2], [1]] + assert validate_get_parser(select_list_items, nested, num_keys=2) is nested + + +@pytest.mark.parametrize( + "parser_args", + [ + torch.tensor(2), + torch.tensor([2]), + np.int64(2), + np.array(2), + np.array([2]), + ], +) +def test_validate_get_parser_coerces_scalar_length_for_slice_tensor(parser_args): + coerced = validate_get_parser(slice_tensor, [parser_args], num_keys=1) + + assert coerced == [2] + assert all(type(x) is int for x in coerced) + assert torch.equal(slice_tensor(torch.arange(5), coerced[0]), torch.arange(2)) + + +@pytest.mark.parametrize( + "parser_args", + [ + torch.tensor([2, 3]), + np.array([2, 3]), + [torch.tensor(2), np.int64(3)], + [np.array(2), torch.tensor([3])], + ], +) +def test_validate_get_parser_coerces_1d_lengths_to_list_int(parser_args): + coerced = validate_get_parser(slice_tensor, parser_args, num_keys=2) + + assert coerced == [2, 3] + assert all(type(x) is int for x in coerced) + field_data = {"input_ids": [torch.arange(4), torch.arange(10, 14)]} + parsed = apply_get_parser(field_data, slice_tensor, coerced, global_indexes=[0, 1]) + assert torch.equal(parsed["input_ids"][0], torch.tensor([0, 1])) + assert torch.equal(parsed["input_ids"][1], torch.tensor([10, 11, 12])) + + +def test_validate_get_parser_rejects_bad_tensor_shapes_early(): + with pytest.raises(ValueError, match="parser_args tensor/array must be 1-d with length 2"): + validate_get_parser(slice_tensor, torch.tensor([[2, 3]]), num_keys=2) + with pytest.raises(ValueError, match=r"parser_args\[0\] must be a 0-d or single-element integer tensor"): + validate_get_parser(slice_tensor, [torch.zeros(2, 2, dtype=torch.int64)], num_keys=1) + with pytest.raises(ValueError, match=r"parser_args\[0\] must be a 0-d or single-element integer tensor"): + validate_get_parser(slice_tensor, [torch.tensor([[2]])], num_keys=1) + with pytest.raises(TypeError, match=r"parser_args\[0\] must be an integer tensor"): + validate_get_parser(slice_tensor, [torch.tensor(2.0)], num_keys=1) + + +def test_validate_get_parser_coerces_0d_tensor_as_whole_args_for_one_key(): + assert validate_get_parser(slice_tensor, torch.tensor(2), num_keys=1) == [2] + assert validate_get_parser(slice_tensor, np.array(2), num_keys=1) == [2] + + +def test_validate_get_parser_does_not_coerce_select_list_items_args(): + """Coerce is slice_tensor-only; index tensors must stay Sequence-like, not bare int.""" + single = [torch.tensor([2])] + assert validate_get_parser(select_list_items, single, num_keys=1) is single + assert select_list_items(["a", "b", "c"], single[0]) == ["c"] + + for indices in (torch.tensor([0, 2]), np.array([0, 2])): + args = [indices] + assert validate_get_parser(select_list_items, args, num_keys=1) is args + assert select_list_items(["a", "b", "c"], indices) == ["a", "c"] + + +def test_validate_get_parser_custom_callable_skips_length_coerce(): + def custom(value, parser_arg): + return value + + args = [torch.tensor([2])] + assert validate_get_parser(custom, args, num_keys=1) is args + + def test_apply_get_parser_uses_each_key_arg_on_every_field(): field_data = {"images": [["a0", "a1", "a2"], ["b0"]], "frames": [["fa0", "fa1", "fa2"], ["fb0", "fb1"]]} diff --git a/transfer_queue/interface.py b/transfer_queue/interface.py index 757233a9..a131a87e 100644 --- a/transfer_queue/interface.py +++ b/transfer_queue/interface.py @@ -54,7 +54,7 @@ from transfer_queue.sampler import BaseSampler from transfer_queue.storage.bootstrap import StorageBootstrapProvider from transfer_queue.storage.managers.simple_storage_manager import AsyncSimpleStorageManager -from transfer_queue.utils.get_parser import GetParser, validate_get_parser +from transfer_queue.utils.get_parser import GetParser, check_parser_select_fields, validate_get_parser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.yuanrong_utils import cleanup_yuanrong_resources from transfer_queue.utils.zmq_utils import process_zmq_server_info @@ -539,12 +539,21 @@ def kv_batch_get( Args: keys: Single key or list of keys to retrieve partition_id: Partition containing the keys - select_fields: Optional field(s) to retrieve. If None, retrieves all fields + select_fields: Optional field(s) to retrieve. If None, retrieves all fields. With a ``parser``, pass + ``select_fields`` limited to compatible fields (required when keys have more than one field). parser: Optional ``parser(value, parser_arg)`` each SimpleStorage unit applies to every selected (key, field) - before replying, e.g. ``transfer_queue.select_list_items``; pair it with ``select_fields``. It runs - synchronously on the storage unit worker, so keep it light and side-effect free. Sent by cloudpickle, so - a module-level parser must be importable on the storage units. KV backends raise NotImplementedError. + after loading/decoding the stored value and before replying, e.g. ``transfer_queue.select_list_items``. + Only the parsed subset is re-serialized on the wire (bandwidth); with Hybrid/SSD offload the full + stored blob may still be read from disk first — parsers do not cut disk I/O by themselves. One get + uses one parser and one per-key ``parser_arg`` for every selected field; heterogeneous field parsing + needs separate gets. Pair the parser with ``select_fields`` for compatible fields (omit only when + the keys have a single field). It runs synchronously on the storage unit worker, so keep it light + and side-effect free. Sent by cloudpickle, so a module-level parser must be importable on the + storage units. KV backends raise NotImplementedError. parser_args: Optional per-key ``parser_arg`` values aligned with ``keys``; None passes None for every key. + For ``slice_tensor`` only, integer ``torch.Tensor`` / ``numpy`` lengths (e.g. ``attention_mask.sum(-1)``) + are coerced to ``int`` / ``list[int]`` before send so storage units never see a Tensor as a slice index. + Other parsers (``select_list_items``, custom callables) leave ``parser_args`` unchanged. Returns: TensorDict with the requested data @@ -553,6 +562,7 @@ def kv_batch_get( ValueError: If keys or partition are not found ValueError: If empty fields exist in any key (sample) ValueError: If a parser is given and some keys are missing + ValueError: If a parser is given without ``select_fields`` and the keys have multiple fields RuntimeError: If the parser fails on a storage unit; the message names the field, global index and parser_arg Example: @@ -566,8 +576,8 @@ def kv_batch_get( ... partition_id="train", ... select_fields="input_ids" ... ) - >>> # Get the images at per-key indices out of a list-valued field; select_fields keeps the parser off - >>> # fields such as input_ids, which select_list_items would reject + >>> # List fields: select_list_items takes per-key Sequence[int] indices (reorder/repeat OK). + >>> # select_fields keeps the parser off tensor fields such as input_ids (use another get for those). >>> data = tq.kv_batch_get( ... keys=["sample_1", "sample_2"], ... partition_id="train", @@ -575,7 +585,8 @@ def kv_batch_get( ... parser=tq.select_list_items, ... parser_args=[[0, 2], [1]], ... ) - >>> # Trim a padded tensor field to each key's real length as a view (only the slice is sent) + >>> # Tensor fields: slice_tensor takes per-key int (dim-0 prefix), slice, or indexing tuple — + >>> # not a list of indices like select_list_items. Contiguous dim-0 prefixes avoid encode materialization. >>> data = tq.kv_batch_get( ... keys=["sample_1", "sample_2"], ... partition_id="train", @@ -585,7 +596,7 @@ def kv_batch_get( ... ) """ keys = [keys] if isinstance(keys, str) else keys - validate_get_parser(parser, parser_args, len(keys)) + parser_args = validate_get_parser(parser, parser_args, len(keys)) tq_client = _maybe_create_tq_client() return tq_client._run_coroutine( async_kv_batch_get( @@ -1000,10 +1011,14 @@ async def async_kv_batch_get( Args: keys: Single key or list of keys to retrieve partition_id: Partition containing the keys - select_fields: Optional field(s) to retrieve. If None, retrieves all fields - parser: Optional get parser each SimpleStorage unit applies to every selected (key, field) before replying; - pair it with ``select_fields`` and keep it light and side-effect free. See ``kv_batch_get``. + select_fields: Optional field(s) to retrieve. If None, retrieves all fields. With a ``parser``, pass + ``select_fields`` limited to compatible fields (required when keys have more than one field). + parser: Optional get parser each SimpleStorage unit applies after load/decode and before replying; + wire size shrinks to the parsed subset, but Hybrid/SSD may still read the full stored blob first. + One get uses one parser for every selected field. Pair with ``select_fields``; omit only when keys + have a single field. Heterogeneous field parsing needs separate gets. See ``kv_batch_get``. parser_args: Optional per-key ``parser_arg`` values aligned with ``keys``; None passes None for every key. + Length coerce applies to ``slice_tensor`` only, same as ``kv_batch_get``. Returns: TensorDict with the requested data @@ -1012,6 +1027,7 @@ async def async_kv_batch_get( ValueError: If keys or partition are not found ValueError: If empty fields exist in any key (sample) ValueError: If a parser is given and some keys are missing + ValueError: If a parser is given without ``select_fields`` and the keys have multiple fields RuntimeError: If the parser fails on a storage unit; the message names the field, global index and parser_arg Example: @@ -1027,7 +1043,7 @@ async def async_kv_batch_get( ... ) """ keys = [keys] if isinstance(keys, str) else keys - validate_get_parser(parser, parser_args, len(keys)) + parser_args = validate_get_parser(parser, parser_args, len(keys)) tq_client = _maybe_create_tq_client() batch_meta = await tq_client.async_kv_retrieve_meta(keys=keys, partition_id=partition_id, create=False) @@ -1035,6 +1051,8 @@ async def async_kv_batch_get( if batch_meta.size == 0: raise ValueError("keys or partition were not found!") _check_all_keys_found(batch_meta.size, keys, partition_id, parser) + # Field set is known only after meta retrieve; fail before get_data when parser would hit all fields. + check_parser_select_fields(parser, select_fields, batch_meta.field_names) if select_fields is not None: if isinstance(select_fields, str): diff --git a/transfer_queue/storage/managers/simple_storage_manager.py b/transfer_queue/storage/managers/simple_storage_manager.py index 175c83db..badaed0b 100644 --- a/transfer_queue/storage/managers/simple_storage_manager.py +++ b/transfer_queue/storage/managers/simple_storage_manager.py @@ -793,7 +793,8 @@ async def get_data( Args: metadata: BatchMeta that contains metadata for data retrieval. - parser: Optional ``parser(value, parser_arg)`` each SU applies to every requested value before replying. + parser: Optional ``parser(value, parser_arg)`` each SU applies after load/decode; only the parsed + subset is serialized on the wire (Hybrid/SSD may still read the full blob first). parser_args: Optional per-sample parser arguments, aligned with ``metadata.global_indexes``. Returns: @@ -804,7 +805,7 @@ async def get_data( if metadata.size == 0: return TensorDict({}, batch_size=0) - validate_get_parser(parser, parser_args, metadata.size) + parser_args = validate_get_parser(parser, parser_args, metadata.size) routing = self._group_by_hash(metadata.global_indexes) diff --git a/transfer_queue/storage/simple_storage.py b/transfer_queue/storage/simple_storage.py index 14ab8b94..62d42d77 100644 --- a/transfer_queue/storage/simple_storage.py +++ b/transfer_queue/storage/simple_storage.py @@ -1295,6 +1295,7 @@ def _handle_get(self, data_parts: ZMQMessage) -> ZMQMessage: with limit_pytorch_auto_parallel_threads( target_num_threads=TQ_NUM_THREADS, info=f"[{self.storage_unit_id}] _handle_get" ): + # get_data may read/decode the full SSD-backed blob; the parser only shrinks the reply wire size. result_data = self.storage_data.get_data(fields, global_indexes) if parser is not None: result_data = apply_get_parser( diff --git a/transfer_queue/utils/get_parser.py b/transfer_queue/utils/get_parser.py index 84350bd6..a74ee737 100644 --- a/transfer_queue/utils/get_parser.py +++ b/transfer_queue/utils/get_parser.py @@ -14,12 +14,21 @@ """Get parsers: callables a SimpleStorage unit applies to stored values before replying to ``kv_batch_get``. -A parser trims a (key, field) value to the part the caller needs, so only that part is serialized and sent.""" +A parser runs after the storage unit has loaded and decoded the stored value. It trims that value +to the part the caller needs; only the parsed subset is re-serialized and sent on the wire +(bandwidth). With Hybrid/SSD offload (and similar), the full stored blob may still be read from +disk and decoded before parsing — ``parser_args`` alone do not reduce disk I/O. + +One get uses one ``parser`` and one per-key ``parser_arg`` for every selected field. Heterogeneous +field parsing (different parsers or args per field) needs separate gets. Pair a parser with +``select_fields`` limited to compatible fields; omitting ``select_fields`` is only valid when the +keys have a single field.""" import reprlib from collections.abc import Callable, Sequence from typing import Any +import numpy as np import torch from tensordict import NonTensorData, NonTensorStack @@ -28,9 +37,12 @@ def select_list_items(value: Sequence[Any], indices: Sequence[int]) -> list[Any]: - """Return the items of a list-valued field at ``indices``, in that order, e.g. some images of an image list. + """Return the items of a list-valued field at ``indices`` (a ``Sequence[int]``), in that order. - An index outside ``[0, len(value))`` raises ``IndexError`` instead of wrapping around.""" + Reorder and repeats are allowed (e.g. ``[3, 0, 3]``). An index outside ``[0, len(value))`` + raises ``IndexError`` instead of wrapping around. This is not the same API as ``slice_tensor``, + which takes a dim-0 prefix length, ``slice``, or indexing ``tuple`` — not a list of indices. + """ if not isinstance(value, list | tuple): raise TypeError(f"select_list_items needs a list or tuple value, got {type(value).__name__}") out_of_range = [index for index in indices if not 0 <= index < len(value)] @@ -40,12 +52,17 @@ def select_list_items(value: Sequence[Any], indices: Sequence[int]) -> list[Any] def slice_tensor(value: torch.Tensor, index: int | slice | tuple[Any, ...]) -> torch.Tensor: - """Return a view of a tensor field sliced by ``index``, so only that slice is serialized on get. + """Return ``value`` sliced by ``index`` so only that logical content is serialized and sent on get. + + ``index`` is an ``int`` dim-0 prefix length (``value[:index]``), a ``slice``, or an indexing + ``tuple`` applied as ``value[index]``. It is not a list of indices like ``select_list_items``; + pass ``[0, 2]`` and you get ``TypeError``, not advanced indexing. Wire size follows the sliced + logical content in all cases. - An ``int`` is a dim-0 prefix length (``value[:index]``); a ``slice`` or ``tuple`` is applied as - ``value[index]``. A contiguous leading-dim prefix of a C-contiguous CPU tensor stays a - contiguous view, so the get encoder can extract its buffer without copying. Non-contiguous - results still work, but encoding will materialize them. + Encode without ``.contiguous()`` materialization only when the sliced result ``is_contiguous()`` + (typical: dim-0 prefix ``[:n]`` of a C-contiguous tensor). Non-contiguous slices such as + ``[:, :k]`` or ``[::2]`` still round-trip correctly, but ``MsgpackEncoder`` may call + ``.contiguous()`` before extracting the buffer. """ if not isinstance(value, torch.Tensor): raise TypeError(f"slice_tensor needs a torch.Tensor value, got {type(value).__name__}") @@ -61,22 +78,118 @@ def slice_tensor(value: torch.Tensor, index: int | slice | tuple[Any, ...]) -> t return value[:index] if isinstance(index, slice | tuple): return value[index] + if isinstance(index, list): + raise TypeError( + "slice_tensor does not take a list of indices (use select_list_items for that); " + "pass an int stop, a slice, or a tuple of slices/ints" + ) raise TypeError( f"slice_tensor index must be an int stop, a slice, or a tuple of slices/ints, got {type(index).__name__}" ) -def validate_get_parser(parser: GetParser | None, parser_args: Sequence[Any] | None, num_keys: int) -> None: - """Check a ``kv_batch_get`` parser request before anything is sent to the storage units.""" +def _coerce_int_like(arg: Any, label: str) -> int: + """Turn a 0-d / single-element integer tensor or numpy scalar into a Python ``int``.""" + if isinstance(arg, torch.Tensor): + if arg.dim() > 1 or arg.numel() != 1: + raise ValueError( + f"{label} must be a 0-d or single-element integer tensor, got shape {tuple(arg.shape)}" + ) + if arg.dtype == torch.bool or torch.is_floating_point(arg) or torch.is_complex(arg): + raise TypeError(f"{label} must be an integer tensor, got dtype {arg.dtype}") + return int(arg.item()) + if isinstance(arg, np.ndarray): + if arg.ndim > 1 or arg.size != 1: + raise ValueError( + f"{label} must be a 0-d or single-element integer array, got shape {tuple(arg.shape)}" + ) + if not np.issubdtype(arg.dtype, np.integer): + raise TypeError(f"{label} must be an integer array, got dtype {arg.dtype}") + return int(arg.item()) + if isinstance(arg, np.integer): + return int(arg) + raise TypeError(f"{label} must be an integer tensor/array/scalar, got {type(arg).__name__}") + + +def _coerce_parser_args(parser_args: Any, num_keys: int) -> Sequence[Any]: + """Coerce ``slice_tensor`` length args from tensor/numpy to ``int`` / ``list[int]``. + + Callers often pass ``attention_mask.sum(-1)`` or ``np.array(lengths)``; those must become + Python ints before the get request is sent so storage units never see a Tensor as a slice index. + """ + if isinstance(parser_args, torch.Tensor | np.ndarray): + if parser_args.ndim == 0 and num_keys == 1: + return [_coerce_int_like(parser_args, "parser_args")] + if parser_args.ndim != 1: + raise ValueError( + f"parser_args tensor/array must be 1-d with length {num_keys}, got shape {tuple(parser_args.shape)}" + ) + if len(parser_args) != num_keys: + raise ValueError( + f"parser_args has {len(parser_args)} entries but {num_keys} keys are fetched; give one per key" + ) + return [_coerce_int_like(item, f"parser_args[{i}]") for i, item in enumerate(parser_args)] + + if not isinstance(parser_args, Sequence) or isinstance(parser_args, (str, bytes)): + raise TypeError( + f"parser_args must be a sequence of per-key args (or a 1-d integer tensor/array), " + f"got {type(parser_args).__name__}" + ) + + coerced: list[Any] = [] + changed = False + for i, arg in enumerate(parser_args): + if isinstance(arg, np.integer) or isinstance(arg, torch.Tensor | np.ndarray): + coerced.append(_coerce_int_like(arg, f"parser_args[{i}]")) + changed = True + else: + coerced.append(arg) + return coerced if changed else parser_args + + +def validate_get_parser( + parser: GetParser | None, parser_args: Sequence[Any] | None, num_keys: int +) -> Sequence[Any] | None: + """Check a ``kv_batch_get`` parser request before anything is sent to the storage units. + + When ``parser is slice_tensor``, returns ``parser_args`` with tensor/numpy length args coerced to + ``int`` / ``list[int]`` (see ``_coerce_parser_args``). Other parsers leave ``parser_args`` + unchanged. Callers must use the return value. + """ if parser is None: if parser_args is not None: raise ValueError("parser_args was given without a parser; pass parser= as well or drop parser_args") - return + return None if not callable(parser): raise TypeError(f"parser must be callable, got {type(parser).__name__}") - if parser_args is not None and len(parser_args) != num_keys: + if parser_args is not None: + if parser is slice_tensor: + parser_args = _coerce_parser_args(parser_args, num_keys) + if len(parser_args) != num_keys: + raise ValueError( + f"parser_args has {len(parser_args)} entries but {num_keys} keys are fetched; give one per key" + ) + return parser_args + + +def check_parser_select_fields( + parser: GetParser | None, + select_fields: list[str] | str | None, + field_names: Sequence[str], +) -> None: + """Fail early when a parser would run on every field of a multi-field get without ``select_fields``. + + One get applies one parser to every selected field; omitting ``select_fields`` is allowed only + when ``field_names`` has at most one field. Heterogeneous parsing needs separate gets. + """ + if parser is None or select_fields is not None: + return + if len(field_names) > 1: raise ValueError( - f"parser_args has {len(parser_args)} entries but {num_keys} keys are fetched; give one per key" + "kv_batch_get with a parser omits select_fields but the keys have multiple fields " + f"({list(field_names)}); one get applies one parser to every selected field. " + "Pass select_fields limited to compatible fields, or issue separate gets for " + "heterogeneous field parsing" ) @@ -86,8 +199,10 @@ def apply_get_parser( parser_args: Sequence[Any] | None, global_indexes: Sequence[int], ) -> dict[str, list[Any]]: - """Replace every stored value by ``parser(value, parser_arg)`` of its key; values arrive unwrapped from NonTensor*. + """Replace every already-loaded value by ``parser(value, parser_arg)`` of its key; values arrive unwrapped from NonTensor*. + Call this only after the storage unit has decoded the stored value (including any SSD read). + The reply then serializes the parsed subset; disk I/O for the full blob is unchanged. Parser errors raise ``ValueError`` (field, global index, arg); kv_batch_get callers get ``RuntimeError``.""" if parser_args is None: parser_args = [None] * len(global_indexes) From 519e74cedc4805a39735833b65ad3493e548387d Mon Sep 17 00:00:00 2001 From: OutstanderWang Date: Tue, 13 Oct 2026 21:06:11 +0800 Subject: [PATCH 06/14] style: fix get_parser pre-commit (UP038, E501, ruff-format) Signed-off-by: OutstanderWang --- transfer_queue/utils/get_parser.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/transfer_queue/utils/get_parser.py b/transfer_queue/utils/get_parser.py index a74ee737..a22eafd6 100644 --- a/transfer_queue/utils/get_parser.py +++ b/transfer_queue/utils/get_parser.py @@ -92,17 +92,13 @@ def _coerce_int_like(arg: Any, label: str) -> int: """Turn a 0-d / single-element integer tensor or numpy scalar into a Python ``int``.""" if isinstance(arg, torch.Tensor): if arg.dim() > 1 or arg.numel() != 1: - raise ValueError( - f"{label} must be a 0-d or single-element integer tensor, got shape {tuple(arg.shape)}" - ) + raise ValueError(f"{label} must be a 0-d or single-element integer tensor, got shape {tuple(arg.shape)}") if arg.dtype == torch.bool or torch.is_floating_point(arg) or torch.is_complex(arg): raise TypeError(f"{label} must be an integer tensor, got dtype {arg.dtype}") return int(arg.item()) if isinstance(arg, np.ndarray): if arg.ndim > 1 or arg.size != 1: - raise ValueError( - f"{label} must be a 0-d or single-element integer array, got shape {tuple(arg.shape)}" - ) + raise ValueError(f"{label} must be a 0-d or single-element integer array, got shape {tuple(arg.shape)}") if not np.issubdtype(arg.dtype, np.integer): raise TypeError(f"{label} must be an integer array, got dtype {arg.dtype}") return int(arg.item()) @@ -130,7 +126,7 @@ def _coerce_parser_args(parser_args: Any, num_keys: int) -> Sequence[Any]: ) return [_coerce_int_like(item, f"parser_args[{i}]") for i, item in enumerate(parser_args)] - if not isinstance(parser_args, Sequence) or isinstance(parser_args, (str, bytes)): + if not isinstance(parser_args, Sequence) or isinstance(parser_args, str | bytes): raise TypeError( f"parser_args must be a sequence of per-key args (or a 1-d integer tensor/array), " f"got {type(parser_args).__name__}" @@ -199,10 +195,11 @@ def apply_get_parser( parser_args: Sequence[Any] | None, global_indexes: Sequence[int], ) -> dict[str, list[Any]]: - """Replace every already-loaded value by ``parser(value, parser_arg)`` of its key; values arrive unwrapped from NonTensor*. + """Replace every already-loaded value by ``parser(value, parser_arg)`` of its key. - Call this only after the storage unit has decoded the stored value (including any SSD read). - The reply then serializes the parsed subset; disk I/O for the full blob is unchanged. + Values arrive unwrapped from NonTensor*. Call this only after the storage unit has decoded + the stored value (including any SSD read). The reply then serializes the parsed subset; + disk I/O for the full blob is unchanged. Parser errors raise ``ValueError`` (field, global index, arg); kv_batch_get callers get ``RuntimeError``.""" if parser_args is None: parser_args = [None] * len(global_indexes) From f15dd10f5f9047469ce762e3854189cb18af3819 Mon Sep 17 00:00:00 2001 From: OutstanderWang Date: Tue, 13 Oct 2026 21:54:20 +0800 Subject: [PATCH 07/14] fix: address PR #191 Codex review on get_parser paths Restore RPC indexing tuples after msgpack list decode, omit parser kwargs for old managers, contain serialize failures so the worker survives, and validate KV parser_args without a parser. Signed-off-by: OutstanderWang --- README.md | 3 +- tests/test_client.py | 25 ++++++++++++++ tests/test_get_parser.py | 34 +++++++++++++++++++ tests/test_kv_storage_manager.py | 12 +++++++ tests/test_simple_storage_unit.py | 30 +++++++++++++++-- transfer_queue/client.py | 7 +++- transfer_queue/storage/managers/base.py | 3 +- transfer_queue/storage/simple_storage.py | 42 ++++++++++++++++++------ transfer_queue/utils/get_parser.py | 41 +++++++++++++++++++---- 9 files changed, 175 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 4df55109..fb082c1b 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,8 @@ In the data plane, we utilize a pluggable design, enabling TransferQueue to inte Specifically, we provide a `StorageManager` abstraction class that defines the core APIs as follows: - `async def put_data(self, data: TensorDict, metadata: BatchMeta) -> None` -- `async def get_data(self, metadata: BatchMeta) -> TensorDict` +- `async def get_data(self, metadata: BatchMeta, parser=None, parser_args=None) -> TensorDict` + (`parser` / `parser_args` are optional kwargs for managers that support get parsers, e.g. SimpleStorage; custom managers may keep `get_data(self, metadata)` and still work for plain gets without a parser) - `async def clear_data(self, metadata: BatchMeta, fields: list[str] | None = None) -> None` This class encapsulates the core interaction logic within the TransferQueue system. You only need to write a simple subclass to integrate your custom storage backend. Refer to the [Customize](#customize) section for details. diff --git a/tests/test_client.py b/tests/test_client.py index 74cfd5d2..e5174c9c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,6 +23,7 @@ from tensordict import NonTensorStack, TensorDict from transfer_queue import TransferQueueClient +from transfer_queue.client import AsyncTransferQueueClient from transfer_queue.metadata import BatchMeta from transfer_queue.utils.enum_utils import Role from transfer_queue.utils.zmq_utils import ( @@ -1472,3 +1473,27 @@ def test_load_storage_checkpoint_without_manager(self, mock_controller, tmp_path with pytest.raises(RuntimeError, match="Storage manager not initialized"): client.load_storage_checkpoint(ckpt_dir) + + +@pytest.mark.asyncio +async def test_async_get_data_omits_parser_kwargs_for_old_managers(): + """Plain gets omit parser kwargs so old get_data(self, metadata) managers still work.""" + + class OldStyleManager: + async def get_data(self, metadata): + return TensorDict({"x": torch.zeros(metadata.size)}, batch_size=[metadata.size]) + + client = AsyncTransferQueueClient.__new__(AsyncTransferQueueClient) + client.client_id = "old_mgr_client" + client.storage_manager = OldStyleManager() + metadata = BatchMeta( + global_indexes=[0], + partition_ids=["0"], + field_schema={"x": {"dtype": torch.float32, "shape": (1,), "is_nested": False, "is_non_tensor": False}}, + ) + + result = await client.async_get_data(metadata) + assert "x" in result + + with pytest.raises(TypeError): + await client.async_get_data(metadata, parser=lambda value, arg: value) diff --git a/tests/test_get_parser.py b/tests/test_get_parser.py index 8ec6ecbf..91b96c86 100644 --- a/tests/test_get_parser.py +++ b/tests/test_get_parser.py @@ -108,6 +108,40 @@ def test_slice_tensor_rejects_index_list_unlike_select_list_items(): slice_tensor(value, [0, 2]) +def test_slice_tensor_restores_indexing_tuple_after_zmq_roundtrip(): + """msgpack turns indexing tuples into lists; RPC parser_args must still slice multi-dim.""" + from transfer_queue.utils.zmq_utils import ZMQMessage, ZMQRequestType + + index = (slice(None), slice(0, 2)) + msg = ZMQMessage.create( + request_type=ZMQRequestType.GET_DATA, + sender_id="test", + body={"parser_args": [index, (1, slice(None))]}, + ) + decoded_args = ZMQMessage.deserialize(msg.serialize()).body["parser_args"] + assert all(isinstance(arg, list) for arg in decoded_args) + + value = torch.arange(24).view(4, 3, 2) + parsed = apply_get_parser( + {"x": [value, value]}, + slice_tensor, + decoded_args, + global_indexes=[0, 1], + ) + assert torch.equal(parsed["x"][0], value[:, :2]) + assert torch.equal(parsed["x"][1], value[1]) + + # Pure int lists stay rejected even after the same wire path. + int_list_msg = ZMQMessage.create( + request_type=ZMQRequestType.GET_DATA, + sender_id="test", + body={"parser_args": [[0, 2]]}, + ) + int_list_args = ZMQMessage.deserialize(int_list_msg.serialize()).body["parser_args"] + with pytest.raises(ValueError, match="does not take a list of indices"): + apply_get_parser({"x": [value]}, slice_tensor, int_list_args, global_indexes=[0]) + + def test_apply_get_parser_slice_tensor_keeps_per_key_views(): field_data = {"input_ids": [torch.arange(4), torch.arange(10, 14)]} diff --git a/tests/test_kv_storage_manager.py b/tests/test_kv_storage_manager.py index 01665c99..5b52f4d2 100644 --- a/tests/test_kv_storage_manager.py +++ b/tests/test_kv_storage_manager.py @@ -527,3 +527,15 @@ def test_get_data_with_parser_raises_not_implemented(test_data_for_put_data): with pytest.raises(NotImplementedError, match="does not support kv_batch_get parsers"): asyncio.run(manager.get_data(test_data_for_put_data["metadata"], parser=lambda value, arg: value)) mock_storage_client.get.assert_not_called() + + +@patch.object(KVStorageManager, "_connect_to_controller", lambda self: None) +def test_get_data_parser_args_without_parser_raises(test_data_for_put_data): + """KV backends must not silently ignore parser_args when parser is None.""" + mock_storage_client = MagicMock() + with patch(f"{STORAGE_CLIENT_FACTORY_PATH}.create", return_value=mock_storage_client): + manager = KVStorageManager(controller_info=MagicMock(), config={"client_name": "MockClient"}) + + with pytest.raises(ValueError, match="parser_args was given without a parser"): + asyncio.run(manager.get_data(test_data_for_put_data["metadata"], parser_args=[[0]])) + mock_storage_client.get.assert_not_called() diff --git a/tests/test_simple_storage_unit.py b/tests/test_simple_storage_unit.py index b8f40a99..fe6e8a6e 100644 --- a/tests/test_simple_storage_unit.py +++ b/tests/test_simple_storage_unit.py @@ -81,11 +81,16 @@ def send_put(self, client_id, global_indexes, field_data, data_parser=None): self.socket.send_multipart(msg.serialize()) return ZMQMessage.deserialize(self.socket.recv_multipart(copy=False)) - def send_get(self, client_id, global_indexes, fields): + def send_get(self, client_id, global_indexes, fields, parser=None, parser_args=None): + body = {"global_indexes": global_indexes, "fields": fields} + if parser is not None: + body["parser"] = parser + if parser_args is not None: + body["parser_args"] = parser_args msg = ZMQMessage.create( request_type=ZMQRequestType.GET_DATA, sender_id=f"mock_client_{client_id}", - body={"global_indexes": global_indexes, "fields": fields}, + body=body, ) self.socket.send_multipart(msg.serialize()) return ZMQMessage.deserialize(self.socket.recv_multipart(copy=False)) @@ -1170,3 +1175,24 @@ def test_undecodable_request_answered_and_worker_survives(storage_setup): response = client.send_put(0, [0], {"val": [torch.tensor([1.0])]}) assert response.request_type == ZMQRequestType.PUT_DATA_RESPONSE client.close() + + +def test_unserializable_get_response_answered_and_worker_survives(storage_setup): + """A get parser that returns a non-encodable value must not kill the sole worker.""" + _, put_get_address, storage_ip = storage_setup + client = MockStorageClient(put_get_address, storage_ip) + + response = client.send_put(0, [0], {"val": [torch.tensor([1.0])]}) + assert response.request_type == ZMQRequestType.PUT_DATA_RESPONSE + + def generator_parser(value, parser_arg): + return (x for x in [1, 2, 3]) + + response = client.send_get(0, [0], ["val"], parser=generator_parser) + assert response.request_type == ZMQRequestType.GET_ERROR + assert "failed to serialize response" in response.body["message"] + + response = client.send_get(0, [0], ["val"]) + assert response.request_type == ZMQRequestType.GET_DATA_RESPONSE + torch.testing.assert_close(response.body["data"]["val"][0], torch.tensor([1.0])) + client.close() diff --git a/transfer_queue/client.py b/transfer_queue/client.py index 77ca0950..c5c4913c 100644 --- a/transfer_queue/client.py +++ b/transfer_queue/client.py @@ -570,7 +570,12 @@ async def async_get_data( with limit_pytorch_auto_parallel_threads( target_num_threads=TQ_NUM_THREADS, info=f"[{self.client_id}] async_get_data" ): - results = await self.storage_manager.get_data(metadata, parser=parser, parser_args=parser_args) + # Only pass parser kwargs when a parser is used so old custom managers + # with get_data(self, metadata) keep working for plain gets. + if parser is not None: + results = await self.storage_manager.get_data(metadata, parser=parser, parser_args=parser_args) + else: + results = await self.storage_manager.get_data(metadata) logger.debug(f"[{self.client_id}]: get_data with {metadata.size} samples successfully.") diff --git a/transfer_queue/storage/managers/base.py b/transfer_queue/storage/managers/base.py index 4f37c78f..b265a4a4 100644 --- a/transfer_queue/storage/managers/base.py +++ b/transfer_queue/storage/managers/base.py @@ -36,7 +36,7 @@ from transfer_queue.metadata import BatchMeta, extract_field_schema from transfer_queue.storage.clients.base import StorageClientFactory -from transfer_queue.utils.get_parser import GetParser +from transfer_queue.utils.get_parser import GetParser, validate_get_parser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.zmq_utils import ( STORAGE_MANAGER_IDENTITY_PREFIX, @@ -844,6 +844,7 @@ async def get_data( Fetches tensors using the provided metadata, reconstructs them with the correct shapes and dtypes, and merge them as a TensorDict according to metadata. """ + validate_get_parser(parser, parser_args, metadata.size) if parser is not None: raise NotImplementedError( f"{type(self).__name__} does not support kv_batch_get parsers: KV backends return whole (key, field) " diff --git a/transfer_queue/storage/simple_storage.py b/transfer_queue/storage/simple_storage.py index 62d42d77..53feed21 100644 --- a/transfer_queue/storage/simple_storage.py +++ b/transfer_queue/storage/simple_storage.py @@ -1150,17 +1150,39 @@ def _process_one_worker_request(self, worker_socket: zmq.Socket, monitor: Any) - }, ) - response_frames = response_msg.serialize() - if operation == ZMQRequestType.GET_DATA: # type: ignore[arg-type] - # This end serializes the get response, so its frames give the true wire size. - log_heavy_operation( - self.storage_unit_id, - "get", - time.perf_counter() - started, - sum(frame_nbytes(frame) or 0 for frame in response_frames), - f"samples={len(request_msg.body.get('global_indexes', []))} " - f"fields={list(request_msg.body.get('fields', []))}", + # Serialize is outside the handler try: a success body can still fail encode + # (e.g. get parser returns a generator). Contain that here so the sole worker lives. + try: + response_frames = response_msg.serialize() + except Exception as e: + logger.error( + f"[{self.storage_unit_id}]: failed to serialize response for {operation} " + f"from sender={request_msg.sender_id}: {type(e).__name__}: {e}" + ) + error_type = ( + ZMQRequestType.GET_ERROR + if operation == ZMQRequestType.GET_DATA # type: ignore[arg-type] + else ZMQRequestType.PUT_GET_ERROR ) + response_frames = ZMQMessage.create( + request_type=error_type, # type: ignore[arg-type] + sender_id=self.storage_unit_id, + body={ + "message": f"{self.storage_unit_id}: failed to serialize response " + f"for {operation}: {type(e).__name__}: {e}" + }, + ).serialize() + else: + if operation == ZMQRequestType.GET_DATA: # type: ignore[arg-type] + # This end serializes the get response, so its frames give the true wire size. + log_heavy_operation( + self.storage_unit_id, + "get", + time.perf_counter() - started, + sum(frame_nbytes(frame) or 0 for frame in response_frames), + f"samples={len(request_msg.body.get('global_indexes', []))} " + f"fields={list(request_msg.body.get('fields', []))}", + ) worker_socket.send_multipart([identity] + response_frames, copy=False) def _handle_put(self, data_parts: ZMQMessage) -> ZMQMessage: diff --git a/transfer_queue/utils/get_parser.py b/transfer_queue/utils/get_parser.py index a22eafd6..9f00d16c 100644 --- a/transfer_queue/utils/get_parser.py +++ b/transfer_queue/utils/get_parser.py @@ -51,13 +51,36 @@ def select_list_items(value: Sequence[Any], indices: Sequence[int]) -> list[Any] return [value[index] for index in indices] +def _indexing_tuple_from_list(index: list) -> tuple[Any, ...] | None: + """Restore a multi-dim indexing tuple that msgpack decoded as a list, or return None. + + Untyped msgpack turns ``(slice(None), slice(0, 2))`` into ``[slice(None), slice(0, 2)]``. + Accept that shape so RPC ``parser_args`` still work; keep rejecting a pure ``list[int]`` + (that is ``select_list_items`` advanced-index style, not ``value[i, j, ...]``). + """ + if not index: + return None + has_slice = False + for item in index: + if isinstance(item, bool): + return None + if isinstance(item, slice): + has_slice = True + elif isinstance(item, int) or item is None or item is Ellipsis: + continue + else: + return None + return tuple(index) if has_slice else None + + def slice_tensor(value: torch.Tensor, index: int | slice | tuple[Any, ...]) -> torch.Tensor: """Return ``value`` sliced by ``index`` so only that logical content is serialized and sent on get. ``index`` is an ``int`` dim-0 prefix length (``value[:index]``), a ``slice``, or an indexing ``tuple`` applied as ``value[index]``. It is not a list of indices like ``select_list_items``; - pass ``[0, 2]`` and you get ``TypeError``, not advanced indexing. Wire size follows the sliced - logical content in all cases. + pass ``[0, 2]`` and you get ``TypeError``, not advanced indexing. After RPC, msgpack may + deliver an indexing tuple as a list that still contains at least one ``slice``; that form is + restored to a tuple. Wire size follows the sliced logical content in all cases. Encode without ``.contiguous()`` materialization only when the sliced result ``is_contiguous()`` (typical: dim-0 prefix ``[:n]`` of a C-contiguous tensor). Non-contiguous slices such as @@ -76,13 +99,17 @@ def slice_tensor(value: torch.Tensor, index: int | slice | tuple[Any, ...]) -> t if not 0 <= index <= value.size(0): raise IndexError(f"prefix length {index} is out of range for dim 0 of size {value.size(0)}") return value[:index] + if isinstance(index, list): + # msgpack loses tuple vs list; only lists with a slice are treated as indexing tuples. + restored = _indexing_tuple_from_list(index) + if restored is None: + raise TypeError( + "slice_tensor does not take a list of indices (use select_list_items for that); " + "pass an int stop, a slice, or a tuple of slices/ints" + ) + index = restored if isinstance(index, slice | tuple): return value[index] - if isinstance(index, list): - raise TypeError( - "slice_tensor does not take a list of indices (use select_list_items for that); " - "pass an int stop, a slice, or a tuple of slices/ints" - ) raise TypeError( f"slice_tensor index must be an int stop, a slice, or a tuple of slices/ints, got {type(index).__name__}" ) From 82ea7f9a46b5b4f68cd668f194a2df2542bbeb74 Mon Sep 17 00:00:00 2001 From: OutstanderWang Date: Wed, 14 Oct 2026 14:56:03 +0800 Subject: [PATCH 08/14] [refactor] kv_batch_get: replace get parsers with declarative select_items Running a user callable on the storage unit let a parser mutate stored values in place (memory-resident ones only, so inconsistently with SSD offload), replayed it on every get retry, and lost tuple indexes over msgpack. The two built-in parsers only ever returned part of a value, so the request now carries that selection as data instead of code. select_items gives one entry per key: a slice, or a list of indices, along dim 0 of every selected value. Units apply it right after loading, which cannot change storage, so retries stay harmless and slices/int lists round-trip unchanged. The manager normalizes tensors, numpy ints and lengths once; select_fields is required, every key must exist, and kv_batch_get_by_meta forwards it. KV backends still raise NotImplementedError. Removed with the callables: utils/get_parser.py and its public exports, the coerce helpers, the parsed reply flag, NonTensor unwrapping, and the response-serialization fallback that only parser output could reach. Signed-off-by: OutstanderWang --- tests/e2e/test_kv_interface_e2e.py | 176 +++------- tests/test_async_simple_storage_manager.py | 79 ++--- tests/test_client.py | 16 +- tests/test_get_parser.py | 322 ------------------ tests/test_kv_storage_manager.py | 18 +- tests/test_simple_storage_unit.py | 49 ++- transfer_queue/__init__.py | 4 - transfer_queue/client.py | 35 +- transfer_queue/interface.py | 125 +++---- transfer_queue/storage/managers/base.py | 27 +- .../managers/simple_storage_manager.py | 57 ++-- transfer_queue/storage/simple_storage.py | 73 ++-- transfer_queue/utils/get_parser.py | 256 -------------- 13 files changed, 244 insertions(+), 993 deletions(-) delete mode 100644 tests/test_get_parser.py delete mode 100644 transfer_queue/utils/get_parser.py diff --git a/tests/e2e/test_kv_interface_e2e.py b/tests/e2e/test_kv_interface_e2e.py index 1c882ef5..ddcf51e7 100644 --- a/tests/e2e/test_kv_interface_e2e.py +++ b/tests/e2e/test_kv_interface_e2e.py @@ -728,161 +728,75 @@ def test_kv_batch_get_nonexistent_key(self, controller, tq_api): assert "not found" in str(e).lower() or "empty" in str(e).lower() -class TestKVBatchGetParserE2E: - """End-to-end tests for kv_batch_get with a parser applied on the SimpleStorage units.""" +class TestKVBatchGetSelectItemsE2E: + """kv_batch_get with select_items applied on the SimpleStorage units.""" @pytest.fixture(autouse=True) def require_simple_storage(self, backend_name): if backend_name != "SimpleStorage": - pytest.skip("get parsers only run on SimpleStorage units") + pytest.skip("select_items runs on SimpleStorage units only") @staticmethod - def put_image_lists(tq_api, partition_id, keys): - image_lists = [ - [torch.full((3, 2, k + i + 1), float(10 * k + i)) for i in range(k + 2)] for k in range(len(keys)) - ] + def put_rows(tq_api, keys): + images = [[torch.full((2, k + i + 1), float(10 * k + i)) for i in range(k + 2)] for k in range(len(keys))] fields = TensorDict( - {"images": NonTensorStack(*image_lists), "input_ids": torch.arange(len(keys) * 4).view(len(keys), 4)}, + {"images": NonTensorStack(*images), "input_ids": torch.arange(len(keys) * 4).view(len(keys), 4)}, batch_size=len(keys), ) - tq_api.kv_batch_put(keys=keys, partition_id=partition_id, fields=fields, tags=[{} for _ in keys]) - return image_lists + meta = tq_api.kv_batch_put(keys=keys, partition_id="test_partition", fields=fields, tags=[{} for _ in keys]) + return images, meta - def test_kv_batch_get_parser_selects_list_items_per_key(self, controller, tq_api): - partition_id = "test_partition" - keys = ["parser_0", "parser_1", "parser_2", "parser_3"] - image_lists = self.put_image_lists(tq_api, partition_id, keys) + @pytest.mark.parametrize( + "select_items", + [[[4, 0], [1], [3, 3, 1]], [[], [], []], [slice(1, 3), slice(0, 1), slice(None)]], + ids=["indices", "empty", "slices"], + ) + def test_selects_list_items_per_key(self, tq_api, select_items): + images, _ = self.put_rows(tq_api, ["items_0", "items_1", "items_2", "items_3"]) retrieved = tq_api.kv_batch_get( - keys=["parser_3", "parser_0", "parser_2"], - partition_id=partition_id, + keys=["items_3", "items_0", "items_2"], + partition_id="test_partition", select_fields="images", - parser=tq.select_list_items, - parser_args=[[4, 0], [1], [3, 3, 1]], + select_items=select_items, ) assert list(retrieved.keys()) == ["images"] - expected = [[image_lists[3][4], image_lists[3][0]], [image_lists[0][1]], [image_lists[2][i] for i in (3, 3, 1)]] - for got_row, expected_row in zip(list(retrieved["images"]), expected, strict=True): - assert len(got_row) == len(expected_row) - for got, want in zip(got_row, expected_row, strict=True): - assert_tensor_equal(got, want) - - full = tq_api.kv_batch_get(keys="parser_3", partition_id=partition_id, select_fields="images") - assert len(full["images"][0]) == len(image_lists[3]) - - def test_kv_batch_get_parser_selects_video_frames_mixed_with_images(self, controller, tq_api): - partition_id = "test_partition" - keys = ["parser_video_0", "parser_video_1"] - # Each row is image, three same-shaped video frames, image, as a VLM row stores them. - rows = [ - [torch.full((3, 4, 8), float(10 * k))] - + [torch.full((3, 4, 4), float(10 * k + f + 1)) for f in range(3)] - + [torch.full((3, 8, 4), float(10 * k + 9))] - for k in range(len(keys)) - ] - fields = TensorDict({"images": NonTensorStack(*rows)}, batch_size=len(keys)) - tq_api.kv_batch_put(keys=keys, partition_id=partition_id, fields=fields, tags=[{} for _ in keys]) - - for parser_args in ([[1], [2]], [[1, 2, 3], [3, 1, 2]], [[0, 2, 4], [1, 3]]): - retrieved = tq_api.kv_batch_get( - keys=keys, - partition_id=partition_id, - select_fields="images", - parser=tq.select_list_items, - parser_args=parser_args, - ) - got_rows = list(retrieved["images"]) - assert len(got_rows) == len(rows) - for got_row, row, item_indices in zip(got_rows, rows, parser_args, strict=True): - assert isinstance(got_row, list) and len(got_row) == len(item_indices) - for got, item_idx in zip(got_row, item_indices, strict=True): - assert_tensor_equal(got, row[item_idx]) - - def test_kv_batch_get_parser_empty_selection(self, controller, tq_api): - partition_id = "test_partition" - keys = ["parser_empty_0", "parser_empty_1"] - self.put_image_lists(tq_api, partition_id, keys) - - retrieved = tq_api.kv_batch_get( - keys=keys, - partition_id=partition_id, - select_fields="images", - parser=tq.select_list_items, - parser_args=[[], []], - ) - - assert list(retrieved["images"]) == [[], []] - - def test_kv_batch_get_parser_trims_tensor_field_to_variable_lengths(self, controller, tq_api): - partition_id = "test_partition" - keys = ["parser_tensor_0", "parser_tensor_1", "parser_tensor_2"] - self.put_image_lists(tq_api, partition_id, keys) + for got, row, items in zip( + list(retrieved["images"]), [images[3], images[0], images[2]], select_items, strict=False + ): + expected = row[items] if isinstance(items, slice) else [row[i] for i in items] + assert len(got) == len(expected) + for got_item, expected_item in zip(got, expected, strict=True): + assert_tensor_equal(got_item, expected_item) + full = tq_api.kv_batch_get(keys="items_3", partition_id="test_partition", select_fields="images") + assert len(full["images"][0]) == len(images[3]) + + def test_trims_tensor_field_to_per_key_lengths(self, tq_api): + _, meta = self.put_rows(tq_api, ["trim_0", "trim_1", "trim_2"]) + select_items = [slice(0, n) for n in torch.tensor([1, 3, 2]).tolist()] + expected = [torch.tensor([0]), torch.tensor([4, 5, 6]), torch.tensor([8, 9])] retrieved = tq_api.kv_batch_get( - keys=keys, - partition_id=partition_id, - select_fields="input_ids", - parser=tq.slice_tensor, - parser_args=[1, 3, 2], + keys=meta.keys, partition_id="test_partition", select_fields="input_ids", select_items=select_items ) + assert_nested_tensor_equal(retrieved["input_ids"], expected) + by_meta = tq_api.kv_batch_get_by_meta(meta, select_fields="input_ids", select_items=select_items) + assert_nested_tensor_equal(by_meta["input_ids"], expected) - assert_nested_tensor_equal( - retrieved["input_ids"], [torch.tensor([0]), torch.tensor([4, 5, 6]), torch.tensor([8, 9])] - ) + def test_rejects_bad_requests(self, tq_api): + keys = ["bad_0", "bad_1"] + self.put_rows(tq_api, keys) - def test_kv_batch_get_parser_out_of_range_index_raises(self, controller, tq_api): - partition_id = "test_partition" - keys = ["parser_oob_0", "parser_oob_1"] - self.put_image_lists(tq_api, partition_id, keys) - - with pytest.raises(RuntimeError, match=r"failed on field 'images'.*indices \[7\] are out of range"): + with pytest.raises(RuntimeError, match="out of range"): tq_api.kv_batch_get( - keys=keys, - partition_id=partition_id, - select_fields="images", - parser=tq.select_list_items, - parser_args=[[0], [7]], + keys=keys, partition_id="test_partition", select_fields="images", select_items=[[0], [7]] ) - - def test_kv_batch_get_parser_exception_reaches_caller(self, controller, tq_api): - partition_id = "test_partition" - keys = ["parser_raise_0"] - self.put_image_lists(tq_api, partition_id, keys) - - with pytest.raises(RuntimeError, match="ZeroDivisionError"): + with pytest.raises(ValueError, match="needs select_fields"): + tq_api.kv_batch_get(keys=keys, partition_id="test_partition", select_items=[[0], [0]]) + with pytest.raises(ValueError, match="only 2 of 3 keys were found"): tq_api.kv_batch_get( - keys=keys, - partition_id=partition_id, - select_fields="images", - parser=lambda value, arg: 1 / 0, - ) - - def test_kv_batch_get_parser_requires_select_fields_for_multi_field(self, controller, tq_api): - partition_id = "test_partition" - keys = ["parser_multi_0"] - self.put_image_lists(tq_api, partition_id, keys) - - with pytest.raises(ValueError, match="omits select_fields but the keys have multiple fields"): - tq_api.kv_batch_get( - keys=keys, - partition_id=partition_id, - parser=tq.select_list_items, - parser_args=[[0]], - ) - - def test_kv_batch_get_parser_missing_key_raises(self, controller, tq_api): - partition_id = "test_partition" - keys = ["parser_missing_0"] - self.put_image_lists(tq_api, partition_id, keys) - - with pytest.raises(ValueError, match="only 1 of 2 keys were found"): - tq_api.kv_batch_get( - keys=["parser_missing_0", "parser_missing_absent"], - partition_id=partition_id, - select_fields="images", - parser=tq.select_list_items, - parser_args=[[0], [0]], + keys=keys + ["absent"], partition_id="test_partition", select_fields="images", select_items=[[0]] * 3 ) diff --git a/tests/test_async_simple_storage_manager.py b/tests/test_async_simple_storage_manager.py index 61864497..b120fd6a 100644 --- a/tests/test_async_simple_storage_manager.py +++ b/tests/test_async_simple_storage_manager.py @@ -24,8 +24,8 @@ from transfer_queue.metadata import BatchMeta from transfer_queue.storage import AsyncSimpleStorageManager +from transfer_queue.storage.managers.simple_storage_manager import _normalize_select_items from transfer_queue.utils.enum_utils import Role -from transfer_queue.utils.get_parser import select_list_items from transfer_queue.utils.zmq_utils import ZMQMessage, ZMQRequestType, ZMQServerInfo @@ -579,19 +579,14 @@ def test_all_none_to_nontensorstack(self): assert result.tolist() == [None, None] -def _two_unit_manager(storage_manager_id: str) -> AsyncSimpleStorageManager: +@pytest.mark.asyncio +async def test_get_data_routes_select_items_by_batch_position(): manager = AsyncSimpleStorageManager.__new__(AsyncSimpleStorageManager) - manager.storage_manager_id = storage_manager_id + manager.storage_manager_id = "test_select_items_routing" manager.storage_unit_infos = { unit_id: ZMQServerInfo(role=Role.STORAGE, id=unit_id, ip="127.0.0.1", ports={"put_get_socket": port}) for unit_id, port in (("storage_0", 19050), ("storage_1", 19051)) } - return manager - - -@pytest.mark.asyncio -async def test_get_data_slices_parser_args_per_storage_unit_by_batch_position(): - manager = _two_unit_manager("test_parser_routing") # global_index % 2 routes 2 and 0 to storage_0, 3 and 1 to storage_1. batch_meta = BatchMeta( global_indexes=[3, 0, 2, 1], @@ -599,48 +594,40 @@ async def test_get_data_slices_parser_args_per_storage_unit_by_batch_position(): field_schema={"images": {"dtype": None, "shape": None, "is_nested": False, "is_non_tensor": True}}, production_status=np.ones(4, dtype=np.int8), ) - parser_args = [["arg_3"], ["arg_0"], ["arg_2"], ["arg_1"]] - requests: dict[str, dict] = {} + requests = {} - async def fake_get(global_indexes, fields, target_storage_unit=None, parser=None, parser_args=None): - requests[target_storage_unit] = {"global_indexes": global_indexes, "parser": parser, "args": parser_args} - values = [f"value_{gi}_{args[0]}" for gi, args in zip(global_indexes, parser_args, strict=True)] - return fields, {"images": values} + async def fake_get(global_indexes, fields, target_storage_unit=None, select_items=None): + requests[target_storage_unit] = (global_indexes, select_items) + return fields, { + "images": [f"value_{gi}_{items[0]}" for gi, items in zip(global_indexes, select_items, strict=False)] + } manager._get_from_single_storage_unit = fake_get - data = await manager.get_data(batch_meta, parser=select_list_items, parser_args=parser_args) + data = await manager.get_data(batch_meta, select_items=[[3], [0], [2], [1]]) - assert requests == { - "storage_0": {"global_indexes": [0, 2], "parser": select_list_items, "args": [["arg_0"], ["arg_2"]]}, - "storage_1": {"global_indexes": [3, 1], "parser": select_list_items, "args": [["arg_3"], ["arg_1"]]}, - } - assert list(data["images"]) == ["value_3_arg_3", "value_0_arg_0", "value_2_arg_2", "value_1_arg_1"] + assert requests == {"storage_0": ([0, 2], [[0], [2]]), "storage_1": ([3, 1], [[3], [1]])} + assert list(data["images"]) == ["value_3_3", "value_0_0", "value_2_2", "value_1_1"] -@pytest.mark.asyncio -async def test_get_with_parser_rejects_reply_without_parsed_flag(): - manager = _two_unit_manager("test_parser_old_unit") - old_unit_reply = ZMQMessage.create( - request_type=ZMQRequestType.GET_DATA_RESPONSE, # type: ignore[arg-type] - sender_id="storage_0", - body={"data": {"images": [["whole_row"]]}}, - ) - socket = Mock() - socket.send_multipart = AsyncMock() - socket.recv_multipart = AsyncMock(return_value=old_unit_reply.serialize()) - get_from_unit = AsyncSimpleStorageManager._get_from_single_storage_unit.__wrapped__ - - with pytest.raises(RuntimeError, match="storage unit storage_0 replied without applying the kv_batch_get parser"): - await get_from_unit( - manager, - [0], - ["images"], - target_storage_unit="storage_0", - parser=select_list_items, - parser_args=[[0]], - socket=socket, - ) +def test_normalize_select_items_accepts_tensors_and_numpy_ints(): + select_items = [slice(np.int64(0), torch.tensor(2)), torch.tensor([3, 0]), np.array([1]), [np.int64(4)]] + + normalized = _normalize_select_items(select_items, num_samples=4) + + assert normalized == [slice(0, 2), [3, 0], [1], [4]] + assert _normalize_select_items(torch.tensor([[0, 1], [1, 0]]), num_samples=2) == [[0, 1], [1, 0]] + - sent_body = ZMQMessage.deserialize(socket.send_multipart.await_args.args[0]).body - assert sent_body["parser"] is select_list_items and sent_body["parser_args"] == [[0]] +@pytest.mark.parametrize( + "select_items, error", + [ + ([slice(0, 1)], ValueError), + ([2, 3], TypeError), + (["0:2", "1"], TypeError), + ([[0.5], [1]], TypeError), + ], +) +def test_normalize_select_items_rejects_bad_entries(select_items, error): + with pytest.raises(error): + _normalize_select_items(select_items, num_samples=2) diff --git a/tests/test_client.py b/tests/test_client.py index e5174c9c..11b54f6a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -435,7 +435,7 @@ def client_setup(mock_controller, mock_storage): async def mock_put_data(data, metadata, data_parser=None): pass # Just pretend to store the data - async def mock_get_data(metadata, **kwargs): + async def mock_get_data(metadata): # Return the test data when requested return TEST_DATA @@ -528,7 +528,7 @@ def test_single_controller_multiple_storages(): async def mock_put_data(data, metadata, data_parser=None): pass # Just pretend to store the data - async def mock_get_data(metadata, **kwargs): + async def mock_get_data(metadata): # Return some test data when requested return TensorDict({"tokens": torch.randint(0, 100, (5, 128))}, batch_size=5) @@ -1476,15 +1476,15 @@ def test_load_storage_checkpoint_without_manager(self, mock_controller, tmp_path @pytest.mark.asyncio -async def test_async_get_data_omits_parser_kwargs_for_old_managers(): - """Plain gets omit parser kwargs so old get_data(self, metadata) managers still work.""" +async def test_async_get_data_passes_select_items_only_when_set(): + """Custom managers written as get_data(self, metadata) keep serving plain gets.""" class OldStyleManager: async def get_data(self, metadata): return TensorDict({"x": torch.zeros(metadata.size)}, batch_size=[metadata.size]) client = AsyncTransferQueueClient.__new__(AsyncTransferQueueClient) - client.client_id = "old_mgr_client" + client.client_id = "old_manager_client" client.storage_manager = OldStyleManager() metadata = BatchMeta( global_indexes=[0], @@ -1492,8 +1492,6 @@ async def get_data(self, metadata): field_schema={"x": {"dtype": torch.float32, "shape": (1,), "is_nested": False, "is_non_tensor": False}}, ) - result = await client.async_get_data(metadata) - assert "x" in result - + assert "x" in await client.async_get_data(metadata) with pytest.raises(TypeError): - await client.async_get_data(metadata, parser=lambda value, arg: value) + await client.async_get_data(metadata, select_items=[slice(0, 1)]) diff --git a/tests/test_get_parser.py b/tests/test_get_parser.py deleted file mode 100644 index 91b96c86..00000000 --- a/tests/test_get_parser.py +++ /dev/null @@ -1,322 +0,0 @@ -# Copyright 2026 The TransferQueue Team -# -# Licensed 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 re - -import numpy as np -import pytest -import torch -from tensordict import NonTensorData, NonTensorStack - -from transfer_queue.utils.get_parser import ( - apply_get_parser, - check_parser_select_fields, - select_list_items, - slice_tensor, - validate_get_parser, -) - - -def test_select_list_items_keeps_requested_order(): - items = [torch.full((2,), float(i)) for i in range(4)] - - selected = select_list_items(items, [3, 0, 3]) - - assert [item[0].item() for item in selected] == [3.0, 0.0, 3.0] - - -def test_select_list_items_empty_selection_returns_empty_list(): - assert select_list_items([1, 2], []) == [] - - -@pytest.mark.parametrize("indices", [[2], [-1]]) -def test_select_list_items_out_of_range_raises(indices): - with pytest.raises(IndexError, match=re.escape(f"indices {indices} are out of range for a list of 2 items")): - select_list_items([1, 2], indices) - - -def test_select_list_items_non_list_value_raises(): - with pytest.raises(TypeError, match="needs a list or tuple value, got Tensor"): - select_list_items(torch.zeros(3), [0]) - - -def test_slice_tensor_prefix_returns_contiguous_view(): - value = torch.arange(12).view(3, 4) - - sliced = slice_tensor(value, 2) - - assert torch.equal(sliced, value[:2]) - assert sliced.data_ptr() == value.data_ptr() - assert sliced.untyped_storage().data_ptr() == value.untyped_storage().data_ptr() - assert sliced.is_contiguous() - - -def test_slice_tensor_accepts_slice_and_tuple_indexes(): - value = torch.arange(24).view(4, 3, 2) - - assert torch.equal(slice_tensor(value, slice(1, 3)), value[1:3]) - assert torch.equal(slice_tensor(value, (slice(None), slice(0, 2))), value[:, :2]) - assert slice_tensor(value, (1, slice(None))).data_ptr() == value[1].data_ptr() - - -def test_slice_tensor_empty_prefix_is_valid_view(): - value = torch.arange(6).view(3, 2) - - sliced = slice_tensor(value, 0) - - assert sliced.shape == (0, 2) - assert sliced.untyped_storage().data_ptr() == value.untyped_storage().data_ptr() - - -@pytest.mark.parametrize("index", [-1, 4]) -def test_slice_tensor_prefix_out_of_range_raises(index): - with pytest.raises(IndexError, match=re.escape(f"prefix length {index} is out of range for dim 0 of size 3")): - slice_tensor(torch.zeros(3, 2), index) - - -def test_slice_tensor_rejects_non_tensor_and_nested(): - with pytest.raises(TypeError, match="needs a torch.Tensor value, got list"): - slice_tensor([1, 2, 3], 1) - nested = torch.nested.as_nested_tensor([torch.ones(2), torch.ones(3)], layout=torch.jagged) - with pytest.raises(TypeError, match="does not support nested tensors"): - slice_tensor(nested, 1) - - -def test_slice_tensor_rejects_bool_and_bad_index_type(): - value = torch.zeros(3) - with pytest.raises(TypeError, match="not bool"): - slice_tensor(value, True) - with pytest.raises(TypeError, match="got str"): - slice_tensor(value, "0:2") - - -def test_slice_tensor_rejects_index_list_unlike_select_list_items(): - """slice_tensor is not advanced indexing; a list of indices is select_list_items-only.""" - value = torch.arange(6).view(3, 2) - with pytest.raises(TypeError, match="does not take a list of indices"): - slice_tensor(value, [0, 2]) - - -def test_slice_tensor_restores_indexing_tuple_after_zmq_roundtrip(): - """msgpack turns indexing tuples into lists; RPC parser_args must still slice multi-dim.""" - from transfer_queue.utils.zmq_utils import ZMQMessage, ZMQRequestType - - index = (slice(None), slice(0, 2)) - msg = ZMQMessage.create( - request_type=ZMQRequestType.GET_DATA, - sender_id="test", - body={"parser_args": [index, (1, slice(None))]}, - ) - decoded_args = ZMQMessage.deserialize(msg.serialize()).body["parser_args"] - assert all(isinstance(arg, list) for arg in decoded_args) - - value = torch.arange(24).view(4, 3, 2) - parsed = apply_get_parser( - {"x": [value, value]}, - slice_tensor, - decoded_args, - global_indexes=[0, 1], - ) - assert torch.equal(parsed["x"][0], value[:, :2]) - assert torch.equal(parsed["x"][1], value[1]) - - # Pure int lists stay rejected even after the same wire path. - int_list_msg = ZMQMessage.create( - request_type=ZMQRequestType.GET_DATA, - sender_id="test", - body={"parser_args": [[0, 2]]}, - ) - int_list_args = ZMQMessage.deserialize(int_list_msg.serialize()).body["parser_args"] - with pytest.raises(ValueError, match="does not take a list of indices"): - apply_get_parser({"x": [value]}, slice_tensor, int_list_args, global_indexes=[0]) - - -def test_apply_get_parser_slice_tensor_keeps_per_key_views(): - field_data = {"input_ids": [torch.arange(4), torch.arange(10, 14)]} - - parsed = apply_get_parser(field_data, slice_tensor, [2, 3], global_indexes=[0, 1]) - - assert torch.equal(parsed["input_ids"][0], torch.tensor([0, 1])) - assert torch.equal(parsed["input_ids"][1], torch.tensor([10, 11, 12])) - assert parsed["input_ids"][0].data_ptr() == field_data["input_ids"][0].data_ptr() - assert parsed["input_ids"][1].data_ptr() == field_data["input_ids"][1].data_ptr() - - -def test_slice_tensor_prefix_encode_sends_only_sliced_nbytes(): - from transfer_queue.utils.serial_utils import MsgpackDecoder, MsgpackEncoder - - value = torch.arange(12, dtype=torch.int64).view(3, 4) - sliced = slice_tensor(value, 2) - assert sliced.is_contiguous() - buffers = MsgpackEncoder().encode(sliced) - - assert buffers[1].nbytes == sliced.nbytes - assert buffers[1].nbytes < value.nbytes - assert torch.equal(MsgpackDecoder().decode(buffers), sliced) - - -def test_slice_tensor_noncontiguous_slice_encodes_correct_values(): - """Column / stride slices are correct; MsgpackEncoder may .contiguous() them first.""" - from transfer_queue.utils.serial_utils import MsgpackDecoder, MsgpackEncoder - - value = torch.arange(12, dtype=torch.int64).view(3, 4) - for index in ((slice(None), slice(0, 2)), slice(None, None, 2)): - sliced = slice_tensor(value, index) - assert not sliced.is_contiguous() - assert torch.equal(MsgpackDecoder().decode(MsgpackEncoder().encode(sliced)), sliced) - - -def test_check_parser_select_fields_rejects_multi_field_without_select_fields(): - with pytest.raises(ValueError, match="omits select_fields but the keys have multiple fields"): - check_parser_select_fields(select_list_items, None, ["images", "input_ids"]) - - -def test_check_parser_select_fields_allows_select_fields_or_single_field(): - check_parser_select_fields(select_list_items, "images", ["images", "input_ids"]) - check_parser_select_fields(select_list_items, ["images"], ["images", "input_ids"]) - check_parser_select_fields(select_list_items, None, ["images"]) - check_parser_select_fields(None, None, ["images", "input_ids"]) - - -def test_validate_get_parser_rejects_args_without_parser(): - with pytest.raises(ValueError, match="parser_args was given without a parser"): - validate_get_parser(None, [[0]], num_keys=1) - - -def test_validate_get_parser_rejects_non_callable(): - with pytest.raises(TypeError, match="parser must be callable, got str"): - validate_get_parser("select_list_items", None, num_keys=1) - - -def test_validate_get_parser_rejects_misaligned_args(): - with pytest.raises(ValueError, match="parser_args has 1 entries but 2 keys are fetched"): - validate_get_parser(select_list_items, [[0]], num_keys=2) - - -def test_validate_get_parser_keeps_plain_int_list_and_slice_args(): - ints = [2, 3] - assert validate_get_parser(slice_tensor, ints, num_keys=2) is ints - slices = [slice(0, 2), (slice(None), slice(0, 1))] - assert validate_get_parser(slice_tensor, slices, num_keys=2) is slices - nested = [[0, 2], [1]] - assert validate_get_parser(select_list_items, nested, num_keys=2) is nested - - -@pytest.mark.parametrize( - "parser_args", - [ - torch.tensor(2), - torch.tensor([2]), - np.int64(2), - np.array(2), - np.array([2]), - ], -) -def test_validate_get_parser_coerces_scalar_length_for_slice_tensor(parser_args): - coerced = validate_get_parser(slice_tensor, [parser_args], num_keys=1) - - assert coerced == [2] - assert all(type(x) is int for x in coerced) - assert torch.equal(slice_tensor(torch.arange(5), coerced[0]), torch.arange(2)) - - -@pytest.mark.parametrize( - "parser_args", - [ - torch.tensor([2, 3]), - np.array([2, 3]), - [torch.tensor(2), np.int64(3)], - [np.array(2), torch.tensor([3])], - ], -) -def test_validate_get_parser_coerces_1d_lengths_to_list_int(parser_args): - coerced = validate_get_parser(slice_tensor, parser_args, num_keys=2) - - assert coerced == [2, 3] - assert all(type(x) is int for x in coerced) - field_data = {"input_ids": [torch.arange(4), torch.arange(10, 14)]} - parsed = apply_get_parser(field_data, slice_tensor, coerced, global_indexes=[0, 1]) - assert torch.equal(parsed["input_ids"][0], torch.tensor([0, 1])) - assert torch.equal(parsed["input_ids"][1], torch.tensor([10, 11, 12])) - - -def test_validate_get_parser_rejects_bad_tensor_shapes_early(): - with pytest.raises(ValueError, match="parser_args tensor/array must be 1-d with length 2"): - validate_get_parser(slice_tensor, torch.tensor([[2, 3]]), num_keys=2) - with pytest.raises(ValueError, match=r"parser_args\[0\] must be a 0-d or single-element integer tensor"): - validate_get_parser(slice_tensor, [torch.zeros(2, 2, dtype=torch.int64)], num_keys=1) - with pytest.raises(ValueError, match=r"parser_args\[0\] must be a 0-d or single-element integer tensor"): - validate_get_parser(slice_tensor, [torch.tensor([[2]])], num_keys=1) - with pytest.raises(TypeError, match=r"parser_args\[0\] must be an integer tensor"): - validate_get_parser(slice_tensor, [torch.tensor(2.0)], num_keys=1) - - -def test_validate_get_parser_coerces_0d_tensor_as_whole_args_for_one_key(): - assert validate_get_parser(slice_tensor, torch.tensor(2), num_keys=1) == [2] - assert validate_get_parser(slice_tensor, np.array(2), num_keys=1) == [2] - - -def test_validate_get_parser_does_not_coerce_select_list_items_args(): - """Coerce is slice_tensor-only; index tensors must stay Sequence-like, not bare int.""" - single = [torch.tensor([2])] - assert validate_get_parser(select_list_items, single, num_keys=1) is single - assert select_list_items(["a", "b", "c"], single[0]) == ["c"] - - for indices in (torch.tensor([0, 2]), np.array([0, 2])): - args = [indices] - assert validate_get_parser(select_list_items, args, num_keys=1) is args - assert select_list_items(["a", "b", "c"], indices) == ["a", "c"] - - -def test_validate_get_parser_custom_callable_skips_length_coerce(): - def custom(value, parser_arg): - return value - - args = [torch.tensor([2])] - assert validate_get_parser(custom, args, num_keys=1) is args - - -def test_apply_get_parser_uses_each_key_arg_on_every_field(): - field_data = {"images": [["a0", "a1", "a2"], ["b0"]], "frames": [["fa0", "fa1", "fa2"], ["fb0", "fb1"]]} - - parsed = apply_get_parser(field_data, select_list_items, [[2, 0], [0]], global_indexes=[5, 9]) - - assert parsed == {"images": [["a2", "a0"], ["b0"]], "frames": [["fa2", "fa0"], ["fb0"]]} - - -def test_apply_get_parser_unwraps_non_tensor_values(): - field_data = {"images": [NonTensorStack("x", "y"), NonTensorData(["z0", "z1"])]} - - parsed = apply_get_parser(field_data, select_list_items, [[1], [0, 1]], global_indexes=[0, 1]) - - assert parsed == {"images": [["y"], ["z0", "z1"]]} - - -def test_apply_get_parser_without_args_passes_none(): - seen_args = [] - - def record_arg(value, parser_arg): - seen_args.append(parser_arg) - return value - - apply_get_parser({"x": [1, 2]}, record_arg, None, global_indexes=[0, 1]) - - assert seen_args == [None, None] - - -def test_apply_get_parser_error_names_field_index_and_arg(): - with pytest.raises(ValueError, match=r"failed on field 'images' of global index 9 with parser_arg \[3\]") as e: - apply_get_parser({"images": [["a0"], ["b0"]]}, select_list_items, [[0], [3]], global_indexes=[5, 9]) - - assert isinstance(e.value.__cause__, IndexError) diff --git a/tests/test_kv_storage_manager.py b/tests/test_kv_storage_manager.py index 5b52f4d2..aed7cda2 100644 --- a/tests/test_kv_storage_manager.py +++ b/tests/test_kv_storage_manager.py @@ -519,23 +519,11 @@ def test_put_data_custom_backend_meta_length_mismatch_raises_error(test_data_for @patch.object(KVStorageManager, "_connect_to_controller", lambda self: None) -def test_get_data_with_parser_raises_not_implemented(test_data_for_put_data): +def test_get_data_with_select_items_raises_before_fetching(test_data_for_put_data): mock_storage_client = MagicMock() with patch(f"{STORAGE_CLIENT_FACTORY_PATH}.create", return_value=mock_storage_client): manager = KVStorageManager(controller_info=MagicMock(), config={"client_name": "MockClient"}) - with pytest.raises(NotImplementedError, match="does not support kv_batch_get parsers"): - asyncio.run(manager.get_data(test_data_for_put_data["metadata"], parser=lambda value, arg: value)) - mock_storage_client.get.assert_not_called() - - -@patch.object(KVStorageManager, "_connect_to_controller", lambda self: None) -def test_get_data_parser_args_without_parser_raises(test_data_for_put_data): - """KV backends must not silently ignore parser_args when parser is None.""" - mock_storage_client = MagicMock() - with patch(f"{STORAGE_CLIENT_FACTORY_PATH}.create", return_value=mock_storage_client): - manager = KVStorageManager(controller_info=MagicMock(), config={"client_name": "MockClient"}) - - with pytest.raises(ValueError, match="parser_args was given without a parser"): - asyncio.run(manager.get_data(test_data_for_put_data["metadata"], parser_args=[[0]])) + with pytest.raises(NotImplementedError, match="does not support select_items"): + asyncio.run(manager.get_data(test_data_for_put_data["metadata"], select_items=[slice(0, 1)])) mock_storage_client.get.assert_not_called() diff --git a/tests/test_simple_storage_unit.py b/tests/test_simple_storage_unit.py index fe6e8a6e..230e1c0b 100644 --- a/tests/test_simple_storage_unit.py +++ b/tests/test_simple_storage_unit.py @@ -46,8 +46,10 @@ SimpleStorageUnit, SSDFileStore, StorageKeyNotFoundError, + _select_items, _SSDValueRef, ) +from transfer_queue.utils.serial_utils import MsgpackDecoder, MsgpackEncoder from transfer_queue.utils.zmq_utils import ( STORAGE_MANAGER_IDENTITY_PREFIX, ZMQMessage, @@ -81,12 +83,10 @@ def send_put(self, client_id, global_indexes, field_data, data_parser=None): self.socket.send_multipart(msg.serialize()) return ZMQMessage.deserialize(self.socket.recv_multipart(copy=False)) - def send_get(self, client_id, global_indexes, fields, parser=None, parser_args=None): + def send_get(self, client_id, global_indexes, fields, select_items=None): body = {"global_indexes": global_indexes, "fields": fields} - if parser is not None: - body["parser"] = parser - if parser_args is not None: - body["parser_args"] = parser_args + if select_items is not None: + body["select_items"] = select_items msg = ZMQMessage.create( request_type=ZMQRequestType.GET_DATA, sender_id=f"mock_client_{client_id}", @@ -1177,22 +1177,35 @@ def test_undecodable_request_answered_and_worker_survives(storage_setup): client.close() -def test_unserializable_get_response_answered_and_worker_survives(storage_setup): - """A get parser that returns a non-encodable value must not kill the sole worker.""" +def test_get_select_items_returns_selected_items_and_keeps_stored_values(storage_setup): _, put_get_address, storage_ip = storage_setup client = MockStorageClient(put_get_address, storage_ip) + tokens = [torch.arange(4), torch.arange(10, 16)] + images = [["a0", "a1", "a2"], ["b0", "b1"]] + client.send_put(0, [0, 1], {"tokens": tokens, "images": images}) - response = client.send_put(0, [0], {"val": [torch.tensor([1.0])]}) - assert response.request_type == ZMQRequestType.PUT_DATA_RESPONSE - - def generator_parser(value, parser_arg): - return (x for x in [1, 2, 3]) - - response = client.send_get(0, [0], ["val"], parser=generator_parser) - assert response.request_type == ZMQRequestType.GET_ERROR - assert "failed to serialize response" in response.body["message"] + response = client.send_get(0, [0, 1], ["tokens", "images"], select_items=[slice(0, 2), [1, 0, 1]]) - response = client.send_get(0, [0], ["val"]) assert response.request_type == ZMQRequestType.GET_DATA_RESPONSE - torch.testing.assert_close(response.body["data"]["val"][0], torch.tensor([1.0])) + data = response.body["data"] + torch.testing.assert_close(data["tokens"][0], torch.tensor([0, 1])) + torch.testing.assert_close(data["tokens"][1], torch.tensor([11, 10, 11])) + assert data["images"] == [["a0", "a1"], ["b1", "b0", "b1"]] + full = client.send_get(0, [0, 1], ["tokens", "images"]).body["data"] + torch.testing.assert_close(full["tokens"][1], tokens[1]) + assert full["images"] == images + + response = client.send_get(0, [0, 1], ["images"], select_items=[[0], [5]]) + assert response.request_type == ZMQRequestType.GET_ERROR + assert "out of range" in response.body["message"] client.close() + + +def test_select_items_slice_sends_only_the_selected_bytes(): + value = torch.arange(12, dtype=torch.int64).view(3, 4) + selected = _select_items(value, slice(0, 2)) + + buffers = MsgpackEncoder().encode(selected) + assert selected.data_ptr() == value.data_ptr() + assert buffers[1].nbytes == selected.nbytes < value.nbytes + assert torch.equal(MsgpackDecoder().decode(buffers), value[:2]) diff --git a/transfer_queue/__init__.py b/transfer_queue/__init__.py index 3554c9df..6c514b2b 100644 --- a/transfer_queue/__init__.py +++ b/transfer_queue/__init__.py @@ -59,7 +59,6 @@ from .sampler.seqlen_balanced_sampler import SeqlenBalancedSampler from .sampler.sequential_sampler import SequentialSampler from .storage import StorageKeyNotFoundError -from .utils.get_parser import GetParser, select_list_items, slice_tensor __all__ = ( [ @@ -84,9 +83,6 @@ "async_kv_clear", "async_kv_update", "KVBatchMeta", - "GetParser", - "select_list_items", - "slice_tensor", ] + [ # KV key locks: process-local and cluster-wide diff --git a/transfer_queue/client.py b/transfer_queue/client.py index c5c4913c..c70ee187 100644 --- a/transfer_queue/client.py +++ b/transfer_queue/client.py @@ -28,7 +28,6 @@ from transfer_queue.metadata import BatchMeta from transfer_queue.storage import StorageManagerFactory from transfer_queue.utils.common import limit_pytorch_auto_parallel_threads -from transfer_queue.utils.get_parser import GetParser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.zmq_utils import ( TQ_SOCKET_POOL_SIZE, @@ -524,19 +523,13 @@ async def async_update( field_schema = await self.storage_manager.update_data(metadata, values, merge_fn) return metadata.apply_field_schema(field_schema) - async def async_get_data( - self, - metadata: BatchMeta, - parser: GetParser | None = None, - parser_args: Sequence[Any] | None = None, - ) -> TensorDict: + async def async_get_data(self, metadata: BatchMeta, select_items: Sequence[Any] | None = None) -> TensorDict: """Asynchronously fetch data from storage units and organize into TensorDict. Args: metadata: Batch metadata containing data location information and global indexes - parser: Optional get parser the storage units apply to every requested value before replying. - See ``transfer_queue.kv_batch_get``; only supported by SimpleStorage. - parser_args: Optional per-sample parser arguments, aligned with ``metadata.global_indexes``. + select_items: Optional per-sample selection inside each value, aligned with + ``metadata.global_indexes``. See ``transfer_queue.kv_batch_get``. Returns: TensorDict containing: @@ -570,12 +563,11 @@ async def async_get_data( with limit_pytorch_auto_parallel_threads( target_num_threads=TQ_NUM_THREADS, info=f"[{self.client_id}] async_get_data" ): - # Only pass parser kwargs when a parser is used so old custom managers - # with get_data(self, metadata) keep working for plain gets. - if parser is not None: - results = await self.storage_manager.get_data(metadata, parser=parser, parser_args=parser_args) - else: + # Passed only when set, so custom managers with get_data(self, metadata) keep working. + if select_items is None: results = await self.storage_manager.get_data(metadata) + else: + results = await self.storage_manager.get_data(metadata, select_items=select_items) logger.debug(f"[{self.client_id}]: get_data with {metadata.size} samples successfully.") @@ -1660,19 +1652,12 @@ def update( """ return self._update(metadata, values, merge_fn) - def get_data( - self, - metadata: BatchMeta, - parser: GetParser | None = None, - parser_args: Sequence[Any] | None = None, - ) -> TensorDict: + def get_data(self, metadata: BatchMeta, select_items: Sequence[Any] | None = None) -> TensorDict: """Synchronously fetch data from storage units and organize into TensorDict. Args: metadata: Batch metadata containing data location information and global indexes - parser: Optional get parser the storage units apply to every requested value before replying. - See ``transfer_queue.kv_batch_get``; only supported by SimpleStorage. - parser_args: Optional per-sample parser arguments, aligned with ``metadata.global_indexes``. + select_items: Optional per-sample selection inside each value. See ``async_get_data``. Returns: TensorDict containing: @@ -1690,7 +1675,7 @@ def get_data( >>> print(batch) >>> # TensorDict with fields "prompts", "attention_mask", and sample order matching metadata global_indexes """ - return self._get_data(metadata=metadata, parser=parser, parser_args=parser_args) + return self._get_data(metadata=metadata, select_items=select_items) def clear_partition(self, partition_id: str): """Synchronously clear the whole partition from all storage units and the controller. diff --git a/transfer_queue/interface.py b/transfer_queue/interface.py index a131a87e..83771a25 100644 --- a/transfer_queue/interface.py +++ b/transfer_queue/interface.py @@ -54,7 +54,6 @@ from transfer_queue.sampler import BaseSampler from transfer_queue.storage.bootstrap import StorageBootstrapProvider from transfer_queue.storage.managers.simple_storage_manager import AsyncSimpleStorageManager -from transfer_queue.utils.get_parser import GetParser, check_parser_select_fields, validate_get_parser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.yuanrong_utils import cleanup_yuanrong_resources from transfer_queue.utils.zmq_utils import process_zmq_server_info @@ -485,7 +484,11 @@ def kv_batch_put( ) -def kv_batch_get_by_meta(meta: KVBatchMeta, select_fields: list[str] | str | None = None) -> TensorDict: +def kv_batch_get_by_meta( + meta: KVBatchMeta, + select_fields: list[str] | str | None = None, + select_items: Sequence[Any] | None = None, +) -> TensorDict: """Get data from TransferQueue using KVBatchMeta. This is a convenience method for retrieving data using KVBatchMeta returned @@ -499,6 +502,7 @@ def kv_batch_get_by_meta(meta: KVBatchMeta, select_fields: list[str] | str | Non recorded in the given KVBatchMeta. If None, uses all fields from meta.fields. Can be a single field name (str) or a list of field names. + select_items: Optional per-key selection inside each value. See ``kv_batch_get``. Returns: TensorDict with the requested data @@ -522,15 +526,16 @@ def kv_batch_get_by_meta(meta: KVBatchMeta, select_fields: list[str] | str | Non >>> data = tq.kv_batch_get_by_meta(meta) """ tq_client = _maybe_create_tq_client() - return tq_client._run_coroutine(async_kv_batch_get_by_meta(meta=meta, select_fields=select_fields)) + return tq_client._run_coroutine( + async_kv_batch_get_by_meta(meta=meta, select_fields=select_fields, select_items=select_items) + ) def kv_batch_get( keys: list[str] | str, partition_id: str, select_fields: list[str] | str | None = None, - parser: GetParser | None = None, - parser_args: Sequence[Any] | None = None, + select_items: Sequence[Any] | None = None, ) -> TensorDict: """Get data from TransferQueue using user-specified keys. @@ -539,21 +544,14 @@ def kv_batch_get( Args: keys: Single key or list of keys to retrieve partition_id: Partition containing the keys - select_fields: Optional field(s) to retrieve. If None, retrieves all fields. With a ``parser``, pass - ``select_fields`` limited to compatible fields (required when keys have more than one field). - parser: Optional ``parser(value, parser_arg)`` each SimpleStorage unit applies to every selected (key, field) - after loading/decoding the stored value and before replying, e.g. ``transfer_queue.select_list_items``. - Only the parsed subset is re-serialized on the wire (bandwidth); with Hybrid/SSD offload the full - stored blob may still be read from disk first — parsers do not cut disk I/O by themselves. One get - uses one parser and one per-key ``parser_arg`` for every selected field; heterogeneous field parsing - needs separate gets. Pair the parser with ``select_fields`` for compatible fields (omit only when - the keys have a single field). It runs synchronously on the storage unit worker, so keep it light - and side-effect free. Sent by cloudpickle, so a module-level parser must be importable on the - storage units. KV backends raise NotImplementedError. - parser_args: Optional per-key ``parser_arg`` values aligned with ``keys``; None passes None for every key. - For ``slice_tensor`` only, integer ``torch.Tensor`` / ``numpy`` lengths (e.g. ``attention_mask.sum(-1)``) - are coerced to ``int`` / ``list[int]`` before send so storage units never see a Tensor as a slice index. - Other parsers (``select_list_items``, custom callables) leave ``parser_args`` unchanged. + select_fields: Optional field(s) to retrieve. If None, retrieves all fields. + Required with ``select_items``. + select_items: Optional per-key selection along dim 0 of every selected value, + aligned with ``keys``: a ``slice`` (a view for tensors) or a list of indices + (reorder and repeats allowed; a copy for tensors). The storage units apply it + before replying, so only the selected items cross the network; SSD-offloaded + values are still read in full. SimpleStorage only; KV backends raise + ``NotImplementedError``. Returns: TensorDict with the requested data @@ -561,9 +559,7 @@ def kv_batch_get( Raises: ValueError: If keys or partition are not found ValueError: If empty fields exist in any key (sample) - ValueError: If a parser is given and some keys are missing - ValueError: If a parser is given without ``select_fields`` and the keys have multiple fields - RuntimeError: If the parser fails on a storage unit; the message names the field, global index and parser_arg + ValueError: If ``select_items`` is given without ``select_fields``, or some keys are missing Example: >>> import transfer_queue as tq @@ -576,48 +572,17 @@ def kv_batch_get( ... partition_id="train", ... select_fields="input_ids" ... ) - >>> # List fields: select_list_items takes per-key Sequence[int] indices (reorder/repeat OK). - >>> # select_fields keeps the parser off tensor fields such as input_ids (use another get for those). - >>> data = tq.kv_batch_get( - ... keys=["sample_1", "sample_2"], - ... partition_id="train", - ... select_fields="images", - ... parser=tq.select_list_items, - ... parser_args=[[0, 2], [1]], - ... ) - >>> # Tensor fields: slice_tensor takes per-key int (dim-0 prefix), slice, or indexing tuple — - >>> # not a list of indices like select_list_items. Contiguous dim-0 prefixes avoid encode materialization. - >>> data = tq.kv_batch_get( - ... keys=["sample_1", "sample_2"], - ... partition_id="train", - ... select_fields="input_ids", - ... parser=tq.slice_tensor, - ... parser_args=[3, 5], - ... ) + >>> # Trim each key's input_ids to its length and take some images of each key + >>> data = tq.kv_batch_get(keys, "train", select_fields="input_ids", + ... select_items=[slice(0, n) for n in lengths.tolist()]) + >>> data = tq.kv_batch_get(keys, "train", select_fields="images", select_items=[[0, 2], [1]]) """ - keys = [keys] if isinstance(keys, str) else keys - parser_args = validate_get_parser(parser, parser_args, len(keys)) tq_client = _maybe_create_tq_client() return tq_client._run_coroutine( - async_kv_batch_get( - keys=keys, - partition_id=partition_id, - select_fields=select_fields, - parser=parser, - parser_args=parser_args, - ) + async_kv_batch_get(keys=keys, partition_id=partition_id, select_fields=select_fields, select_items=select_items) ) -def _check_all_keys_found(num_found: int, keys: list[str], partition_id: str, parser: GetParser | None) -> None: - # NOTE(neowywang): a missing key is dropped from the meta, which would shift parser_args onto other keys. - if parser is not None and num_found != len(keys): - raise ValueError( - f"only {num_found} of {len(keys)} keys were found in partition {partition_id!r}; a kv_batch_get " - "with a parser needs every key, since parser_args are aligned with keys" - ) - - def kv_list(partition_id: str | None = None) -> dict[str, dict[str, Any]]: """List all keys and their metadata in one or all partitions. @@ -940,7 +905,11 @@ async def async_kv_batch_put( ) -async def async_kv_batch_get_by_meta(meta: KVBatchMeta, select_fields: list[str] | str | None = None) -> TensorDict: +async def async_kv_batch_get_by_meta( + meta: KVBatchMeta, + select_fields: list[str] | str | None = None, + select_items: Sequence[Any] | None = None, +) -> TensorDict: """Asynchronously get data from TransferQueue using KVBatchMeta. This is a convenience method for retrieving data using KVBatchMeta returned @@ -954,6 +923,7 @@ async def async_kv_batch_get_by_meta(meta: KVBatchMeta, select_fields: list[str] recorded in the given KVBatchMeta. If None, uses all fields from meta.fields. Can be a single field name (str) or a list of field names. + select_items: Optional per-key selection inside each value. See ``kv_batch_get``. Returns: TensorDict with the requested data @@ -994,15 +964,16 @@ async def async_kv_batch_get_by_meta(meta: KVBatchMeta, select_fields: list[str] ) else: fields_to_fetch = meta.fields - return await async_kv_batch_get(keys=meta.keys, partition_id=meta.partition_id, select_fields=fields_to_fetch) + return await async_kv_batch_get( + keys=meta.keys, partition_id=meta.partition_id, select_fields=fields_to_fetch, select_items=select_items + ) async def async_kv_batch_get( keys: list[str] | str, partition_id: str, select_fields: list[str] | str | None = None, - parser: GetParser | None = None, - parser_args: Sequence[Any] | None = None, + select_items: Sequence[Any] | None = None, ) -> TensorDict: """Asynchronously get data from TransferQueue using user-specified keys. @@ -1011,14 +982,9 @@ async def async_kv_batch_get( Args: keys: Single key or list of keys to retrieve partition_id: Partition containing the keys - select_fields: Optional field(s) to retrieve. If None, retrieves all fields. With a ``parser``, pass - ``select_fields`` limited to compatible fields (required when keys have more than one field). - parser: Optional get parser each SimpleStorage unit applies after load/decode and before replying; - wire size shrinks to the parsed subset, but Hybrid/SSD may still read the full stored blob first. - One get uses one parser for every selected field. Pair with ``select_fields``; omit only when keys - have a single field. Heterogeneous field parsing needs separate gets. See ``kv_batch_get``. - parser_args: Optional per-key ``parser_arg`` values aligned with ``keys``; None passes None for every key. - Length coerce applies to ``slice_tensor`` only, same as ``kv_batch_get``. + select_fields: Optional field(s) to retrieve. If None, retrieves all fields. + Required with ``select_items``. + select_items: Optional per-key selection inside each value. See ``kv_batch_get``. Returns: TensorDict with the requested data @@ -1026,9 +992,7 @@ async def async_kv_batch_get( Raises: ValueError: If keys or partition are not found ValueError: If empty fields exist in any key (sample) - ValueError: If a parser is given and some keys are missing - ValueError: If a parser is given without ``select_fields`` and the keys have multiple fields - RuntimeError: If the parser fails on a storage unit; the message names the field, global index and parser_arg + ValueError: If ``select_items`` is given without ``select_fields``, or some keys are missing Example: >>> import transfer_queue as tq @@ -1042,17 +1006,18 @@ async def async_kv_batch_get( ... select_fields="input_ids" ... ) """ - keys = [keys] if isinstance(keys, str) else keys - parser_args = validate_get_parser(parser, parser_args, len(keys)) + if select_items is not None and select_fields is None: + raise ValueError("select_items needs select_fields, since it applies to every selected field") tq_client = _maybe_create_tq_client() batch_meta = await tq_client.async_kv_retrieve_meta(keys=keys, partition_id=partition_id, create=False) if batch_meta.size == 0: raise ValueError("keys or partition were not found!") - _check_all_keys_found(batch_meta.size, keys, partition_id, parser) - # Field set is known only after meta retrieve; fail before get_data when parser would hit all fields. - check_parser_select_fields(parser, select_fields, batch_meta.field_names) + # Missing keys are dropped from the meta, which would shift select_items onto other keys. + num_keys = 1 if isinstance(keys, str) else len(keys) + if select_items is not None and batch_meta.size != num_keys: + raise ValueError(f"only {batch_meta.size} of {num_keys} keys were found; select_items needs every key") if select_fields is not None: if isinstance(select_fields, str): @@ -1064,7 +1029,7 @@ async def async_kv_batch_get( if not batch_meta.is_ready: raise ValueError("Some fields are not ready in all the requested keys!") - data = await tq_client.async_get_data(batch_meta, parser=parser, parser_args=parser_args) + data = await tq_client.async_get_data(batch_meta, select_items=select_items) return data diff --git a/transfer_queue/storage/managers/base.py b/transfer_queue/storage/managers/base.py index b265a4a4..9efd24bb 100644 --- a/transfer_queue/storage/managers/base.py +++ b/transfer_queue/storage/managers/base.py @@ -36,7 +36,6 @@ from transfer_queue.metadata import BatchMeta, extract_field_schema from transfer_queue.storage.clients.base import StorageClientFactory -from transfer_queue.utils.get_parser import GetParser, validate_get_parser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.zmq_utils import ( STORAGE_MANAGER_IDENTITY_PREFIX, @@ -336,19 +335,14 @@ async def put_data( raise NotImplementedError("Subclasses must implement put_data") @abstractmethod - async def get_data( - self, - metadata: BatchMeta, - parser: GetParser | None = None, - parser_args: Sequence[Any] | None = None, - ) -> TensorDict: + async def get_data(self, metadata: BatchMeta, select_items: Sequence[Any] | None = None) -> TensorDict: """ Get data from the storage backend. Args: metadata: BatchMeta of the data to be retrieved from the storage. - parser: Optional get parser applied to every requested value where it is stored, before sending. - parser_args: Optional per-sample parser arguments, aligned with ``metadata.global_indexes``. + select_items: Optional per-sample selection inside each value, aligned with + ``metadata.global_indexes``. See ``transfer_queue.kv_batch_get``. Returns: TensorDict containing the data retrieved from the storage. @@ -832,24 +826,17 @@ async def put_data( per_field_custom_backend_meta, ) - async def get_data( - self, - metadata: BatchMeta, - parser: GetParser | None = None, - parser_args: Sequence[Any] | None = None, - ) -> TensorDict: + async def get_data(self, metadata: BatchMeta, select_items: Sequence[Any] | None = None) -> TensorDict: """ Retrieve tensor data from the backend storage. Fetches tensors using the provided metadata, reconstructs them with the correct shapes and dtypes, and merge them as a TensorDict according to metadata. """ - validate_get_parser(parser, parser_args, metadata.size) - if parser is not None: + if select_items is not None: raise NotImplementedError( - f"{type(self).__name__} does not support kv_batch_get parsers: KV backends return whole (key, field) " - "values and cannot run a parser where the data is stored. Use the SimpleStorage backend, or fetch " - "without parser= and select the needed part on the caller side" + f"{type(self).__name__} does not support select_items: KV backends return whole values. " + "Use the SimpleStorage backend, or select on the caller side" ) if not metadata.field_names: logger.warning("Attempted to get data, but metadata contains no fields.") diff --git a/transfer_queue/storage/managers/simple_storage_manager.py b/transfer_queue/storage/managers/simple_storage_manager.py index badaed0b..592540e8 100644 --- a/transfer_queue/storage/managers/simple_storage_manager.py +++ b/transfer_queue/storage/managers/simple_storage_manager.py @@ -14,6 +14,7 @@ # limitations under the License. import asyncio +import operator import os import time import warnings @@ -24,6 +25,7 @@ from pathlib import Path from typing import Any, Callable, NamedTuple +import numpy as np import torch import zmq from omegaconf import DictConfig @@ -36,7 +38,6 @@ StorageKeyNotFoundError, ) from transfer_queue.utils.common import log_heavy_operation -from transfer_queue.utils.get_parser import GetParser, validate_get_parser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.zmq_utils import ( TQ_SOCKET_POOL_SIZE, @@ -158,6 +159,28 @@ def _build_update_field_schema( ) +def _normalize_select_items(select_items: Sequence[Any], num_samples: int) -> list[slice | list[int]]: + """Turn per-sample selections into plain slices and int lists that round-trip to the units.""" + if isinstance(select_items, torch.Tensor | np.ndarray): + select_items = select_items.tolist() + if not isinstance(select_items, Sequence) or isinstance(select_items, str | bytes): + raise TypeError(f"select_items must be a sequence with one entry per key, got {type(select_items).__name__}") + if len(select_items) != num_samples: + raise ValueError(f"select_items has {len(select_items)} entries for {num_samples} keys; give one per key") + normalized: list[slice | list[int]] = [] + for item in select_items: + if isinstance(item, torch.Tensor | np.ndarray): + item = item.tolist() + if isinstance(item, slice): + bounds = (item.start, item.stop, item.step) + normalized.append(slice(*(None if b is None else operator.index(b) for b in bounds))) + elif isinstance(item, Sequence) and not isinstance(item, str | bytes): + normalized.append([operator.index(i) for i in item]) + else: + raise TypeError(f"each select_items entry must be a slice or a list of indices, got {type(item).__name__}") + return normalized + + class RoutingGroup(NamedTuple): """Routing result for a single storage unit.""" @@ -780,12 +803,7 @@ def _pack_field_values(values: list) -> torch.Tensor | NonTensorStack: # Mixed tensor + None — cannot create nested tensor, fall through to NonTensorStack return NonTensorStack(*values) - async def get_data( - self, - metadata: BatchMeta, - parser: GetParser | None = None, - parser_args: Sequence[Any] | None = None, - ) -> TensorDict: + async def get_data(self, metadata: BatchMeta, select_items: Sequence[Any] | None = None) -> TensorDict: """ Retrieve data from remote StorageUnit based on metadata. @@ -793,9 +811,8 @@ async def get_data( Args: metadata: BatchMeta that contains metadata for data retrieval. - parser: Optional ``parser(value, parser_arg)`` each SU applies after load/decode; only the parsed - subset is serialized on the wire (Hybrid/SSD may still read the full blob first). - parser_args: Optional per-sample parser arguments, aligned with ``metadata.global_indexes``. + select_items: Optional per-sample selection along dim 0 of each value, aligned with + ``metadata.global_indexes``; each unit applies it before replying. Returns: TensorDict containing the retrieved data. @@ -805,7 +822,8 @@ async def get_data( if metadata.size == 0: return TensorDict({}, batch_size=0) - parser_args = validate_get_parser(parser, parser_args, metadata.size) + if select_items is not None: + select_items = _normalize_select_items(select_items, metadata.size) routing = self._group_by_hash(metadata.global_indexes) @@ -819,8 +837,7 @@ async def get_data( group.global_indexes, metadata.field_names, target_storage_unit=su_id, - parser=parser, - parser_args=None if parser_args is None else [parser_args[pos] for pos in group.batch_positions], + select_items=None if select_items is None else [select_items[p] for p in group.batch_positions], ), ) for su_id, group in routing.items() @@ -859,14 +876,13 @@ async def _get_from_single_storage_unit( global_indexes: list[int], fields: list[str], target_storage_unit: str, - parser: GetParser | None = None, - parser_args: list[Any] | None = None, + select_items: list[slice | list[int]] | None = None, socket: zmq.Socket = None, ): """Get data from a single SU by global index keys.""" - body = {"global_indexes": global_indexes, "fields": fields} - if parser is not None: - body.update(parser=parser, parser_args=parser_args) + body: dict[str, Any] = {"global_indexes": global_indexes, "fields": fields} + if select_items is not None: + body["select_items"] = select_items request_msg = ZMQMessage.create( request_type=ZMQRequestType.GET_DATA, # type: ignore[arg-type] sender_id=self.storage_manager_id, @@ -879,11 +895,6 @@ async def _get_from_single_storage_unit( response_msg = ZMQMessage.deserialize(messages) if response_msg.request_type == ZMQRequestType.GET_DATA_RESPONSE: - if parser is not None and not response_msg.body.get("parsed", False): - raise RuntimeError( - f"storage unit {target_storage_unit} replied without applying the kv_batch_get parser; it " - "runs a TransferQueue older than get parsers, so restart the storage units on this version" - ) storage_unit_data = response_msg.body["data"] return fields, storage_unit_data else: diff --git a/transfer_queue/storage/simple_storage.py b/transfer_queue/storage/simple_storage.py index 53feed21..f7927cc8 100644 --- a/transfer_queue/storage/simple_storage.py +++ b/transfer_queue/storage/simple_storage.py @@ -51,7 +51,6 @@ from transfer_queue.utils import compact_pickle from transfer_queue.utils.common import limit_pytorch_auto_parallel_threads, log_heavy_operation from transfer_queue.utils.enum_utils import Role -from transfer_queue.utils.get_parser import apply_get_parser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.perf_utils import IntervalPerfMonitor from transfer_queue.utils.zmq_utils import ( @@ -113,6 +112,16 @@ class _SSDValueRef: TQ_ACCEPT_PROBE_INTERVAL = float(os.environ.get("TQ_ACCEPT_PROBE_INTERVAL", 0)) +def _select_items(value: Any, items: slice | list[int]) -> Any: + """Select along dim 0 without modifying the stored value. + + A slice of a tensor is a view, an index list of a tensor is a copy, and a list value yields a new list. + """ + if isinstance(items, slice) or isinstance(value, torch.Tensor): + return value[items] + return [value[i] for i in items] + + def _describe_stored_values(values: list) -> dict[str, Any]: """Describe per-sample stored values for the manager. @@ -1150,39 +1159,17 @@ def _process_one_worker_request(self, worker_socket: zmq.Socket, monitor: Any) - }, ) - # Serialize is outside the handler try: a success body can still fail encode - # (e.g. get parser returns a generator). Contain that here so the sole worker lives. - try: - response_frames = response_msg.serialize() - except Exception as e: - logger.error( - f"[{self.storage_unit_id}]: failed to serialize response for {operation} " - f"from sender={request_msg.sender_id}: {type(e).__name__}: {e}" - ) - error_type = ( - ZMQRequestType.GET_ERROR - if operation == ZMQRequestType.GET_DATA # type: ignore[arg-type] - else ZMQRequestType.PUT_GET_ERROR + response_frames = response_msg.serialize() + if operation == ZMQRequestType.GET_DATA: # type: ignore[arg-type] + # This end serializes the get response, so its frames give the true wire size. + log_heavy_operation( + self.storage_unit_id, + "get", + time.perf_counter() - started, + sum(frame_nbytes(frame) or 0 for frame in response_frames), + f"samples={len(request_msg.body.get('global_indexes', []))} " + f"fields={list(request_msg.body.get('fields', []))}", ) - response_frames = ZMQMessage.create( - request_type=error_type, # type: ignore[arg-type] - sender_id=self.storage_unit_id, - body={ - "message": f"{self.storage_unit_id}: failed to serialize response " - f"for {operation}: {type(e).__name__}: {e}" - }, - ).serialize() - else: - if operation == ZMQRequestType.GET_DATA: # type: ignore[arg-type] - # This end serializes the get response, so its frames give the true wire size. - log_heavy_operation( - self.storage_unit_id, - "get", - time.perf_counter() - started, - sum(frame_nbytes(frame) or 0 for frame in response_frames), - f"samples={len(request_msg.body.get('global_indexes', []))} " - f"fields={list(request_msg.body.get('fields', []))}", - ) worker_socket.send_multipart([identity] + response_frames, copy=False) def _handle_put(self, data_parts: ZMQMessage) -> ZMQMessage: @@ -1312,26 +1299,24 @@ def _handle_get(self, data_parts: ZMQMessage) -> ZMQMessage: try: fields = data_parts.body["fields"] global_indexes = data_parts.body["global_indexes"] - parser = data_parts.body.get("parser") with limit_pytorch_auto_parallel_threads( target_num_threads=TQ_NUM_THREADS, info=f"[{self.storage_unit_id}] _handle_get" ): - # get_data may read/decode the full SSD-backed blob; the parser only shrinks the reply wire size. result_data = self.storage_data.get_data(fields, global_indexes) - if parser is not None: - result_data = apply_get_parser( - result_data, parser, data_parts.body.get("parser_args"), global_indexes - ) + select_items = data_parts.body.get("select_items") + if select_items is not None: + result_data = { + field: [_select_items(value, items) for value, items in zip(values, select_items, strict=True)] + for field, values in result_data.items() + } - response_body: dict[str, Any] = {"data": result_data} - if parser is not None: - # NOTE(neowywang): lets the manager tell a parsed reply from an older unit that ignored the parser. - response_body["parsed"] = True response_msg = ZMQMessage.create( request_type=ZMQRequestType.GET_DATA_RESPONSE, # type: ignore[arg-type] sender_id=self.storage_unit_id, - body=response_body, + body={ + "data": result_data, + }, ) except Exception as e: key_not_found = isinstance(e, StorageKeyNotFoundError) diff --git a/transfer_queue/utils/get_parser.py b/transfer_queue/utils/get_parser.py deleted file mode 100644 index 9f00d16c..00000000 --- a/transfer_queue/utils/get_parser.py +++ /dev/null @@ -1,256 +0,0 @@ -# Copyright 2026 The TransferQueue Team -# -# Licensed 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. - -"""Get parsers: callables a SimpleStorage unit applies to stored values before replying to ``kv_batch_get``. - -A parser runs after the storage unit has loaded and decoded the stored value. It trims that value -to the part the caller needs; only the parsed subset is re-serialized and sent on the wire -(bandwidth). With Hybrid/SSD offload (and similar), the full stored blob may still be read from -disk and decoded before parsing — ``parser_args`` alone do not reduce disk I/O. - -One get uses one ``parser`` and one per-key ``parser_arg`` for every selected field. Heterogeneous -field parsing (different parsers or args per field) needs separate gets. Pair a parser with -``select_fields`` limited to compatible fields; omitting ``select_fields`` is only valid when the -keys have a single field.""" - -import reprlib -from collections.abc import Callable, Sequence -from typing import Any - -import numpy as np -import torch -from tensordict import NonTensorData, NonTensorStack - -GetParser = Callable[[Any, Any], Any] -"""``parser(value, parser_arg) -> parsed value``, called once per selected (key, field).""" - - -def select_list_items(value: Sequence[Any], indices: Sequence[int]) -> list[Any]: - """Return the items of a list-valued field at ``indices`` (a ``Sequence[int]``), in that order. - - Reorder and repeats are allowed (e.g. ``[3, 0, 3]``). An index outside ``[0, len(value))`` - raises ``IndexError`` instead of wrapping around. This is not the same API as ``slice_tensor``, - which takes a dim-0 prefix length, ``slice``, or indexing ``tuple`` — not a list of indices. - """ - if not isinstance(value, list | tuple): - raise TypeError(f"select_list_items needs a list or tuple value, got {type(value).__name__}") - out_of_range = [index for index in indices if not 0 <= index < len(value)] - if out_of_range: - raise IndexError(f"indices {out_of_range} are out of range for a list of {len(value)} items") - return [value[index] for index in indices] - - -def _indexing_tuple_from_list(index: list) -> tuple[Any, ...] | None: - """Restore a multi-dim indexing tuple that msgpack decoded as a list, or return None. - - Untyped msgpack turns ``(slice(None), slice(0, 2))`` into ``[slice(None), slice(0, 2)]``. - Accept that shape so RPC ``parser_args`` still work; keep rejecting a pure ``list[int]`` - (that is ``select_list_items`` advanced-index style, not ``value[i, j, ...]``). - """ - if not index: - return None - has_slice = False - for item in index: - if isinstance(item, bool): - return None - if isinstance(item, slice): - has_slice = True - elif isinstance(item, int) or item is None or item is Ellipsis: - continue - else: - return None - return tuple(index) if has_slice else None - - -def slice_tensor(value: torch.Tensor, index: int | slice | tuple[Any, ...]) -> torch.Tensor: - """Return ``value`` sliced by ``index`` so only that logical content is serialized and sent on get. - - ``index`` is an ``int`` dim-0 prefix length (``value[:index]``), a ``slice``, or an indexing - ``tuple`` applied as ``value[index]``. It is not a list of indices like ``select_list_items``; - pass ``[0, 2]`` and you get ``TypeError``, not advanced indexing. After RPC, msgpack may - deliver an indexing tuple as a list that still contains at least one ``slice``; that form is - restored to a tuple. Wire size follows the sliced logical content in all cases. - - Encode without ``.contiguous()`` materialization only when the sliced result ``is_contiguous()`` - (typical: dim-0 prefix ``[:n]`` of a C-contiguous tensor). Non-contiguous slices such as - ``[:, :k]`` or ``[::2]`` still round-trip correctly, but ``MsgpackEncoder`` may call - ``.contiguous()`` before extracting the buffer. - """ - if not isinstance(value, torch.Tensor): - raise TypeError(f"slice_tensor needs a torch.Tensor value, got {type(value).__name__}") - if value.is_nested: - raise TypeError("slice_tensor does not support nested tensors; pass a regular tensor field") - if isinstance(index, bool): - raise TypeError("slice_tensor index must be an int stop, a slice, or a tuple of slices/ints, not bool") - if isinstance(index, int): - if value.dim() == 0: - raise IndexError("slice_tensor cannot take a prefix length of a 0-dim tensor") - if not 0 <= index <= value.size(0): - raise IndexError(f"prefix length {index} is out of range for dim 0 of size {value.size(0)}") - return value[:index] - if isinstance(index, list): - # msgpack loses tuple vs list; only lists with a slice are treated as indexing tuples. - restored = _indexing_tuple_from_list(index) - if restored is None: - raise TypeError( - "slice_tensor does not take a list of indices (use select_list_items for that); " - "pass an int stop, a slice, or a tuple of slices/ints" - ) - index = restored - if isinstance(index, slice | tuple): - return value[index] - raise TypeError( - f"slice_tensor index must be an int stop, a slice, or a tuple of slices/ints, got {type(index).__name__}" - ) - - -def _coerce_int_like(arg: Any, label: str) -> int: - """Turn a 0-d / single-element integer tensor or numpy scalar into a Python ``int``.""" - if isinstance(arg, torch.Tensor): - if arg.dim() > 1 or arg.numel() != 1: - raise ValueError(f"{label} must be a 0-d or single-element integer tensor, got shape {tuple(arg.shape)}") - if arg.dtype == torch.bool or torch.is_floating_point(arg) or torch.is_complex(arg): - raise TypeError(f"{label} must be an integer tensor, got dtype {arg.dtype}") - return int(arg.item()) - if isinstance(arg, np.ndarray): - if arg.ndim > 1 or arg.size != 1: - raise ValueError(f"{label} must be a 0-d or single-element integer array, got shape {tuple(arg.shape)}") - if not np.issubdtype(arg.dtype, np.integer): - raise TypeError(f"{label} must be an integer array, got dtype {arg.dtype}") - return int(arg.item()) - if isinstance(arg, np.integer): - return int(arg) - raise TypeError(f"{label} must be an integer tensor/array/scalar, got {type(arg).__name__}") - - -def _coerce_parser_args(parser_args: Any, num_keys: int) -> Sequence[Any]: - """Coerce ``slice_tensor`` length args from tensor/numpy to ``int`` / ``list[int]``. - - Callers often pass ``attention_mask.sum(-1)`` or ``np.array(lengths)``; those must become - Python ints before the get request is sent so storage units never see a Tensor as a slice index. - """ - if isinstance(parser_args, torch.Tensor | np.ndarray): - if parser_args.ndim == 0 and num_keys == 1: - return [_coerce_int_like(parser_args, "parser_args")] - if parser_args.ndim != 1: - raise ValueError( - f"parser_args tensor/array must be 1-d with length {num_keys}, got shape {tuple(parser_args.shape)}" - ) - if len(parser_args) != num_keys: - raise ValueError( - f"parser_args has {len(parser_args)} entries but {num_keys} keys are fetched; give one per key" - ) - return [_coerce_int_like(item, f"parser_args[{i}]") for i, item in enumerate(parser_args)] - - if not isinstance(parser_args, Sequence) or isinstance(parser_args, str | bytes): - raise TypeError( - f"parser_args must be a sequence of per-key args (or a 1-d integer tensor/array), " - f"got {type(parser_args).__name__}" - ) - - coerced: list[Any] = [] - changed = False - for i, arg in enumerate(parser_args): - if isinstance(arg, np.integer) or isinstance(arg, torch.Tensor | np.ndarray): - coerced.append(_coerce_int_like(arg, f"parser_args[{i}]")) - changed = True - else: - coerced.append(arg) - return coerced if changed else parser_args - - -def validate_get_parser( - parser: GetParser | None, parser_args: Sequence[Any] | None, num_keys: int -) -> Sequence[Any] | None: - """Check a ``kv_batch_get`` parser request before anything is sent to the storage units. - - When ``parser is slice_tensor``, returns ``parser_args`` with tensor/numpy length args coerced to - ``int`` / ``list[int]`` (see ``_coerce_parser_args``). Other parsers leave ``parser_args`` - unchanged. Callers must use the return value. - """ - if parser is None: - if parser_args is not None: - raise ValueError("parser_args was given without a parser; pass parser= as well or drop parser_args") - return None - if not callable(parser): - raise TypeError(f"parser must be callable, got {type(parser).__name__}") - if parser_args is not None: - if parser is slice_tensor: - parser_args = _coerce_parser_args(parser_args, num_keys) - if len(parser_args) != num_keys: - raise ValueError( - f"parser_args has {len(parser_args)} entries but {num_keys} keys are fetched; give one per key" - ) - return parser_args - - -def check_parser_select_fields( - parser: GetParser | None, - select_fields: list[str] | str | None, - field_names: Sequence[str], -) -> None: - """Fail early when a parser would run on every field of a multi-field get without ``select_fields``. - - One get applies one parser to every selected field; omitting ``select_fields`` is allowed only - when ``field_names`` has at most one field. Heterogeneous parsing needs separate gets. - """ - if parser is None or select_fields is not None: - return - if len(field_names) > 1: - raise ValueError( - "kv_batch_get with a parser omits select_fields but the keys have multiple fields " - f"({list(field_names)}); one get applies one parser to every selected field. " - "Pass select_fields limited to compatible fields, or issue separate gets for " - "heterogeneous field parsing" - ) - - -def apply_get_parser( - field_data: dict[str, list[Any]], - parser: GetParser, - parser_args: Sequence[Any] | None, - global_indexes: Sequence[int], -) -> dict[str, list[Any]]: - """Replace every already-loaded value by ``parser(value, parser_arg)`` of its key. - - Values arrive unwrapped from NonTensor*. Call this only after the storage unit has decoded - the stored value (including any SSD read). The reply then serializes the parsed subset; - disk I/O for the full blob is unchanged. - Parser errors raise ``ValueError`` (field, global index, arg); kv_batch_get callers get ``RuntimeError``.""" - if parser_args is None: - parser_args = [None] * len(global_indexes) - elif len(parser_args) != len(global_indexes): - raise ValueError(f"got {len(parser_args)} parser_args for {len(global_indexes)} global indexes") - parser_name = getattr(parser, "__qualname__", type(parser).__name__) - parsed: dict[str, list[Any]] = {} - for field, values in field_data.items(): - column = [] - for value, parser_arg, global_index in zip(values, parser_args, global_indexes, strict=True): - try: - column.append(parser(_unwrap_non_tensor(value), parser_arg)) - except Exception as e: - raise ValueError( - f"get parser {parser_name} failed on field {field!r} of global index {global_index} " - f"with parser_arg {reprlib.repr(parser_arg)}: {type(e).__name__}: {e}" - ) from e - parsed[field] = column - return parsed - - -def _unwrap_non_tensor(value: Any) -> Any: - if isinstance(value, NonTensorStack): - return value.tolist() - if isinstance(value, NonTensorData): - return value.data - return value From a5c6205398a77be81e74db3559cce90c4fd9cb54 Mon Sep 17 00:00:00 2001 From: OutstanderWang Date: Wed, 14 Oct 2026 14:56:03 +0800 Subject: [PATCH 09/14] docs: align StorageManager put_data/get_data signatures in README Signed-off-by: OutstanderWang --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fb082c1b..ed692f6c 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,8 @@ In the data plane, we utilize a pluggable design, enabling TransferQueue to inte Specifically, we provide a `StorageManager` abstraction class that defines the core APIs as follows: -- `async def put_data(self, data: TensorDict, metadata: BatchMeta) -> None` -- `async def get_data(self, metadata: BatchMeta, parser=None, parser_args=None) -> TensorDict` - (`parser` / `parser_args` are optional kwargs for managers that support get parsers, e.g. SimpleStorage; custom managers may keep `get_data(self, metadata)` and still work for plain gets without a parser) +- `async def put_data(self, data: TensorDict, metadata: BatchMeta, data_parser=None) -> None` +- `async def get_data(self, metadata: BatchMeta, select_items=None) -> TensorDict` - `async def clear_data(self, metadata: BatchMeta, fields: list[str] | None = None) -> None` This class encapsulates the core interaction logic within the TransferQueue system. You only need to write a simple subclass to integrate your custom storage backend. Refer to the [Customize](#customize) section for details. @@ -116,7 +115,7 @@ To simplify the usage of TransferQueue, we provide a Redis-style high-level API - **(async_)kv_put**: Insert/Update a multi-column sample by key, with an optional metadata tag. - **(async_)kv_batch_put**: Put multiple key-value pairs efficiently in batches. -- **(async_)kv_batch_get**: Retrieve samples (by keys), supporting column selection (by fields). +- **(async_)kv_batch_get**: Retrieve samples (by keys), supporting column selection (by fields) and, on SimpleStorage, per-key item selection inside each value (`select_items`). - **(async_)kv_update / (async_)kv_batch_update**: Merge new values into already-produced fields with `merge_fn(old, new)`. SimpleStorage only. - **(async_)kv_list**: List keys and tags (metadata) in a partition. - **(async_)kv_clear**: Remove key-value pairs from storage, or only selected `fields` while keeping the keys. From 47826f6e86059c2a329ae4a57e367c949dc29678 Mon Sep 17 00:00:00 2001 From: OutstanderWang Date: Wed, 14 Oct 2026 15:06:39 +0800 Subject: [PATCH 10/14] docs: note that select_items slices clip to the value length Signed-off-by: OutstanderWang --- transfer_queue/interface.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/transfer_queue/interface.py b/transfer_queue/interface.py index 83771a25..4138d5e1 100644 --- a/transfer_queue/interface.py +++ b/transfer_queue/interface.py @@ -548,7 +548,9 @@ def kv_batch_get( Required with ``select_items``. select_items: Optional per-key selection along dim 0 of every selected value, aligned with ``keys``: a ``slice`` (a view for tensors) or a list of indices - (reorder and repeats allowed; a copy for tensors). The storage units apply it + (reorder and repeats allowed; a copy for tensors). A slice follows Python + semantics and is silently clipped to the value's length (``slice(0, 10)`` on + 4 items returns 4), while an out-of-range index raises. The storage units apply it before replying, so only the selected items cross the network; SSD-offloaded values are still read in full. SimpleStorage only; KV backends raise ``NotImplementedError``. From 533668ca4fba14dd881babdf204b79e8a2e5d48c Mon Sep 17 00:00:00 2001 From: OutstanderWang Date: Wed, 14 Oct 2026 17:08:10 +0800 Subject: [PATCH 11/14] Revert "docs: note that select_items slices clip to the value length" This reverts commit 47826f6e86059c2a329ae4a57e367c949dc29678. --- transfer_queue/interface.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/transfer_queue/interface.py b/transfer_queue/interface.py index 4138d5e1..83771a25 100644 --- a/transfer_queue/interface.py +++ b/transfer_queue/interface.py @@ -548,9 +548,7 @@ def kv_batch_get( Required with ``select_items``. select_items: Optional per-key selection along dim 0 of every selected value, aligned with ``keys``: a ``slice`` (a view for tensors) or a list of indices - (reorder and repeats allowed; a copy for tensors). A slice follows Python - semantics and is silently clipped to the value's length (``slice(0, 10)`` on - 4 items returns 4), while an out-of-range index raises. The storage units apply it + (reorder and repeats allowed; a copy for tensors). The storage units apply it before replying, so only the selected items cross the network; SSD-offloaded values are still read in full. SimpleStorage only; KV backends raise ``NotImplementedError``. From 001e95aa129c2eef82d3676168344390422671c0 Mon Sep 17 00:00:00 2001 From: OutstanderWang Date: Wed, 14 Oct 2026 17:08:10 +0800 Subject: [PATCH 12/14] Revert "[refactor] kv_batch_get: replace get parsers with declarative select_items" This reverts commit 82ea7f9a46b5b4f68cd668f194a2df2542bbeb74. --- tests/e2e/test_kv_interface_e2e.py | 176 +++++++--- tests/test_async_simple_storage_manager.py | 79 +++-- tests/test_client.py | 16 +- tests/test_get_parser.py | 322 ++++++++++++++++++ tests/test_kv_storage_manager.py | 18 +- tests/test_simple_storage_unit.py | 49 +-- transfer_queue/__init__.py | 4 + transfer_queue/client.py | 35 +- transfer_queue/interface.py | 125 ++++--- transfer_queue/storage/managers/base.py | 27 +- .../managers/simple_storage_manager.py | 57 ++-- transfer_queue/storage/simple_storage.py | 73 ++-- transfer_queue/utils/get_parser.py | 256 ++++++++++++++ 13 files changed, 993 insertions(+), 244 deletions(-) create mode 100644 tests/test_get_parser.py create mode 100644 transfer_queue/utils/get_parser.py diff --git a/tests/e2e/test_kv_interface_e2e.py b/tests/e2e/test_kv_interface_e2e.py index ddcf51e7..1c882ef5 100644 --- a/tests/e2e/test_kv_interface_e2e.py +++ b/tests/e2e/test_kv_interface_e2e.py @@ -728,75 +728,161 @@ def test_kv_batch_get_nonexistent_key(self, controller, tq_api): assert "not found" in str(e).lower() or "empty" in str(e).lower() -class TestKVBatchGetSelectItemsE2E: - """kv_batch_get with select_items applied on the SimpleStorage units.""" +class TestKVBatchGetParserE2E: + """End-to-end tests for kv_batch_get with a parser applied on the SimpleStorage units.""" @pytest.fixture(autouse=True) def require_simple_storage(self, backend_name): if backend_name != "SimpleStorage": - pytest.skip("select_items runs on SimpleStorage units only") + pytest.skip("get parsers only run on SimpleStorage units") @staticmethod - def put_rows(tq_api, keys): - images = [[torch.full((2, k + i + 1), float(10 * k + i)) for i in range(k + 2)] for k in range(len(keys))] + def put_image_lists(tq_api, partition_id, keys): + image_lists = [ + [torch.full((3, 2, k + i + 1), float(10 * k + i)) for i in range(k + 2)] for k in range(len(keys)) + ] fields = TensorDict( - {"images": NonTensorStack(*images), "input_ids": torch.arange(len(keys) * 4).view(len(keys), 4)}, + {"images": NonTensorStack(*image_lists), "input_ids": torch.arange(len(keys) * 4).view(len(keys), 4)}, batch_size=len(keys), ) - meta = tq_api.kv_batch_put(keys=keys, partition_id="test_partition", fields=fields, tags=[{} for _ in keys]) - return images, meta + tq_api.kv_batch_put(keys=keys, partition_id=partition_id, fields=fields, tags=[{} for _ in keys]) + return image_lists - @pytest.mark.parametrize( - "select_items", - [[[4, 0], [1], [3, 3, 1]], [[], [], []], [slice(1, 3), slice(0, 1), slice(None)]], - ids=["indices", "empty", "slices"], - ) - def test_selects_list_items_per_key(self, tq_api, select_items): - images, _ = self.put_rows(tq_api, ["items_0", "items_1", "items_2", "items_3"]) + def test_kv_batch_get_parser_selects_list_items_per_key(self, controller, tq_api): + partition_id = "test_partition" + keys = ["parser_0", "parser_1", "parser_2", "parser_3"] + image_lists = self.put_image_lists(tq_api, partition_id, keys) retrieved = tq_api.kv_batch_get( - keys=["items_3", "items_0", "items_2"], - partition_id="test_partition", + keys=["parser_3", "parser_0", "parser_2"], + partition_id=partition_id, select_fields="images", - select_items=select_items, + parser=tq.select_list_items, + parser_args=[[4, 0], [1], [3, 3, 1]], ) assert list(retrieved.keys()) == ["images"] - for got, row, items in zip( - list(retrieved["images"]), [images[3], images[0], images[2]], select_items, strict=False - ): - expected = row[items] if isinstance(items, slice) else [row[i] for i in items] - assert len(got) == len(expected) - for got_item, expected_item in zip(got, expected, strict=True): - assert_tensor_equal(got_item, expected_item) - full = tq_api.kv_batch_get(keys="items_3", partition_id="test_partition", select_fields="images") - assert len(full["images"][0]) == len(images[3]) - - def test_trims_tensor_field_to_per_key_lengths(self, tq_api): - _, meta = self.put_rows(tq_api, ["trim_0", "trim_1", "trim_2"]) - select_items = [slice(0, n) for n in torch.tensor([1, 3, 2]).tolist()] - expected = [torch.tensor([0]), torch.tensor([4, 5, 6]), torch.tensor([8, 9])] + expected = [[image_lists[3][4], image_lists[3][0]], [image_lists[0][1]], [image_lists[2][i] for i in (3, 3, 1)]] + for got_row, expected_row in zip(list(retrieved["images"]), expected, strict=True): + assert len(got_row) == len(expected_row) + for got, want in zip(got_row, expected_row, strict=True): + assert_tensor_equal(got, want) + + full = tq_api.kv_batch_get(keys="parser_3", partition_id=partition_id, select_fields="images") + assert len(full["images"][0]) == len(image_lists[3]) + + def test_kv_batch_get_parser_selects_video_frames_mixed_with_images(self, controller, tq_api): + partition_id = "test_partition" + keys = ["parser_video_0", "parser_video_1"] + # Each row is image, three same-shaped video frames, image, as a VLM row stores them. + rows = [ + [torch.full((3, 4, 8), float(10 * k))] + + [torch.full((3, 4, 4), float(10 * k + f + 1)) for f in range(3)] + + [torch.full((3, 8, 4), float(10 * k + 9))] + for k in range(len(keys)) + ] + fields = TensorDict({"images": NonTensorStack(*rows)}, batch_size=len(keys)) + tq_api.kv_batch_put(keys=keys, partition_id=partition_id, fields=fields, tags=[{} for _ in keys]) + + for parser_args in ([[1], [2]], [[1, 2, 3], [3, 1, 2]], [[0, 2, 4], [1, 3]]): + retrieved = tq_api.kv_batch_get( + keys=keys, + partition_id=partition_id, + select_fields="images", + parser=tq.select_list_items, + parser_args=parser_args, + ) + got_rows = list(retrieved["images"]) + assert len(got_rows) == len(rows) + for got_row, row, item_indices in zip(got_rows, rows, parser_args, strict=True): + assert isinstance(got_row, list) and len(got_row) == len(item_indices) + for got, item_idx in zip(got_row, item_indices, strict=True): + assert_tensor_equal(got, row[item_idx]) + + def test_kv_batch_get_parser_empty_selection(self, controller, tq_api): + partition_id = "test_partition" + keys = ["parser_empty_0", "parser_empty_1"] + self.put_image_lists(tq_api, partition_id, keys) + + retrieved = tq_api.kv_batch_get( + keys=keys, + partition_id=partition_id, + select_fields="images", + parser=tq.select_list_items, + parser_args=[[], []], + ) + + assert list(retrieved["images"]) == [[], []] + + def test_kv_batch_get_parser_trims_tensor_field_to_variable_lengths(self, controller, tq_api): + partition_id = "test_partition" + keys = ["parser_tensor_0", "parser_tensor_1", "parser_tensor_2"] + self.put_image_lists(tq_api, partition_id, keys) retrieved = tq_api.kv_batch_get( - keys=meta.keys, partition_id="test_partition", select_fields="input_ids", select_items=select_items + keys=keys, + partition_id=partition_id, + select_fields="input_ids", + parser=tq.slice_tensor, + parser_args=[1, 3, 2], ) - assert_nested_tensor_equal(retrieved["input_ids"], expected) - by_meta = tq_api.kv_batch_get_by_meta(meta, select_fields="input_ids", select_items=select_items) - assert_nested_tensor_equal(by_meta["input_ids"], expected) - def test_rejects_bad_requests(self, tq_api): - keys = ["bad_0", "bad_1"] - self.put_rows(tq_api, keys) + assert_nested_tensor_equal( + retrieved["input_ids"], [torch.tensor([0]), torch.tensor([4, 5, 6]), torch.tensor([8, 9])] + ) - with pytest.raises(RuntimeError, match="out of range"): + def test_kv_batch_get_parser_out_of_range_index_raises(self, controller, tq_api): + partition_id = "test_partition" + keys = ["parser_oob_0", "parser_oob_1"] + self.put_image_lists(tq_api, partition_id, keys) + + with pytest.raises(RuntimeError, match=r"failed on field 'images'.*indices \[7\] are out of range"): tq_api.kv_batch_get( - keys=keys, partition_id="test_partition", select_fields="images", select_items=[[0], [7]] + keys=keys, + partition_id=partition_id, + select_fields="images", + parser=tq.select_list_items, + parser_args=[[0], [7]], ) - with pytest.raises(ValueError, match="needs select_fields"): - tq_api.kv_batch_get(keys=keys, partition_id="test_partition", select_items=[[0], [0]]) - with pytest.raises(ValueError, match="only 2 of 3 keys were found"): + + def test_kv_batch_get_parser_exception_reaches_caller(self, controller, tq_api): + partition_id = "test_partition" + keys = ["parser_raise_0"] + self.put_image_lists(tq_api, partition_id, keys) + + with pytest.raises(RuntimeError, match="ZeroDivisionError"): tq_api.kv_batch_get( - keys=keys + ["absent"], partition_id="test_partition", select_fields="images", select_items=[[0]] * 3 + keys=keys, + partition_id=partition_id, + select_fields="images", + parser=lambda value, arg: 1 / 0, + ) + + def test_kv_batch_get_parser_requires_select_fields_for_multi_field(self, controller, tq_api): + partition_id = "test_partition" + keys = ["parser_multi_0"] + self.put_image_lists(tq_api, partition_id, keys) + + with pytest.raises(ValueError, match="omits select_fields but the keys have multiple fields"): + tq_api.kv_batch_get( + keys=keys, + partition_id=partition_id, + parser=tq.select_list_items, + parser_args=[[0]], + ) + + def test_kv_batch_get_parser_missing_key_raises(self, controller, tq_api): + partition_id = "test_partition" + keys = ["parser_missing_0"] + self.put_image_lists(tq_api, partition_id, keys) + + with pytest.raises(ValueError, match="only 1 of 2 keys were found"): + tq_api.kv_batch_get( + keys=["parser_missing_0", "parser_missing_absent"], + partition_id=partition_id, + select_fields="images", + parser=tq.select_list_items, + parser_args=[[0], [0]], ) diff --git a/tests/test_async_simple_storage_manager.py b/tests/test_async_simple_storage_manager.py index b120fd6a..61864497 100644 --- a/tests/test_async_simple_storage_manager.py +++ b/tests/test_async_simple_storage_manager.py @@ -24,8 +24,8 @@ from transfer_queue.metadata import BatchMeta from transfer_queue.storage import AsyncSimpleStorageManager -from transfer_queue.storage.managers.simple_storage_manager import _normalize_select_items from transfer_queue.utils.enum_utils import Role +from transfer_queue.utils.get_parser import select_list_items from transfer_queue.utils.zmq_utils import ZMQMessage, ZMQRequestType, ZMQServerInfo @@ -579,14 +579,19 @@ def test_all_none_to_nontensorstack(self): assert result.tolist() == [None, None] -@pytest.mark.asyncio -async def test_get_data_routes_select_items_by_batch_position(): +def _two_unit_manager(storage_manager_id: str) -> AsyncSimpleStorageManager: manager = AsyncSimpleStorageManager.__new__(AsyncSimpleStorageManager) - manager.storage_manager_id = "test_select_items_routing" + manager.storage_manager_id = storage_manager_id manager.storage_unit_infos = { unit_id: ZMQServerInfo(role=Role.STORAGE, id=unit_id, ip="127.0.0.1", ports={"put_get_socket": port}) for unit_id, port in (("storage_0", 19050), ("storage_1", 19051)) } + return manager + + +@pytest.mark.asyncio +async def test_get_data_slices_parser_args_per_storage_unit_by_batch_position(): + manager = _two_unit_manager("test_parser_routing") # global_index % 2 routes 2 and 0 to storage_0, 3 and 1 to storage_1. batch_meta = BatchMeta( global_indexes=[3, 0, 2, 1], @@ -594,40 +599,48 @@ async def test_get_data_routes_select_items_by_batch_position(): field_schema={"images": {"dtype": None, "shape": None, "is_nested": False, "is_non_tensor": True}}, production_status=np.ones(4, dtype=np.int8), ) - requests = {} + parser_args = [["arg_3"], ["arg_0"], ["arg_2"], ["arg_1"]] + requests: dict[str, dict] = {} - async def fake_get(global_indexes, fields, target_storage_unit=None, select_items=None): - requests[target_storage_unit] = (global_indexes, select_items) - return fields, { - "images": [f"value_{gi}_{items[0]}" for gi, items in zip(global_indexes, select_items, strict=False)] - } + async def fake_get(global_indexes, fields, target_storage_unit=None, parser=None, parser_args=None): + requests[target_storage_unit] = {"global_indexes": global_indexes, "parser": parser, "args": parser_args} + values = [f"value_{gi}_{args[0]}" for gi, args in zip(global_indexes, parser_args, strict=True)] + return fields, {"images": values} manager._get_from_single_storage_unit = fake_get - data = await manager.get_data(batch_meta, select_items=[[3], [0], [2], [1]]) - - assert requests == {"storage_0": ([0, 2], [[0], [2]]), "storage_1": ([3, 1], [[3], [1]])} - assert list(data["images"]) == ["value_3_3", "value_0_0", "value_2_2", "value_1_1"] - + data = await manager.get_data(batch_meta, parser=select_list_items, parser_args=parser_args) -def test_normalize_select_items_accepts_tensors_and_numpy_ints(): - select_items = [slice(np.int64(0), torch.tensor(2)), torch.tensor([3, 0]), np.array([1]), [np.int64(4)]] - - normalized = _normalize_select_items(select_items, num_samples=4) + assert requests == { + "storage_0": {"global_indexes": [0, 2], "parser": select_list_items, "args": [["arg_0"], ["arg_2"]]}, + "storage_1": {"global_indexes": [3, 1], "parser": select_list_items, "args": [["arg_3"], ["arg_1"]]}, + } + assert list(data["images"]) == ["value_3_arg_3", "value_0_arg_0", "value_2_arg_2", "value_1_arg_1"] - assert normalized == [slice(0, 2), [3, 0], [1], [4]] - assert _normalize_select_items(torch.tensor([[0, 1], [1, 0]]), num_samples=2) == [[0, 1], [1, 0]] +@pytest.mark.asyncio +async def test_get_with_parser_rejects_reply_without_parsed_flag(): + manager = _two_unit_manager("test_parser_old_unit") + old_unit_reply = ZMQMessage.create( + request_type=ZMQRequestType.GET_DATA_RESPONSE, # type: ignore[arg-type] + sender_id="storage_0", + body={"data": {"images": [["whole_row"]]}}, + ) + socket = Mock() + socket.send_multipart = AsyncMock() + socket.recv_multipart = AsyncMock(return_value=old_unit_reply.serialize()) + get_from_unit = AsyncSimpleStorageManager._get_from_single_storage_unit.__wrapped__ + + with pytest.raises(RuntimeError, match="storage unit storage_0 replied without applying the kv_batch_get parser"): + await get_from_unit( + manager, + [0], + ["images"], + target_storage_unit="storage_0", + parser=select_list_items, + parser_args=[[0]], + socket=socket, + ) -@pytest.mark.parametrize( - "select_items, error", - [ - ([slice(0, 1)], ValueError), - ([2, 3], TypeError), - (["0:2", "1"], TypeError), - ([[0.5], [1]], TypeError), - ], -) -def test_normalize_select_items_rejects_bad_entries(select_items, error): - with pytest.raises(error): - _normalize_select_items(select_items, num_samples=2) + sent_body = ZMQMessage.deserialize(socket.send_multipart.await_args.args[0]).body + assert sent_body["parser"] is select_list_items and sent_body["parser_args"] == [[0]] diff --git a/tests/test_client.py b/tests/test_client.py index 11b54f6a..e5174c9c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -435,7 +435,7 @@ def client_setup(mock_controller, mock_storage): async def mock_put_data(data, metadata, data_parser=None): pass # Just pretend to store the data - async def mock_get_data(metadata): + async def mock_get_data(metadata, **kwargs): # Return the test data when requested return TEST_DATA @@ -528,7 +528,7 @@ def test_single_controller_multiple_storages(): async def mock_put_data(data, metadata, data_parser=None): pass # Just pretend to store the data - async def mock_get_data(metadata): + async def mock_get_data(metadata, **kwargs): # Return some test data when requested return TensorDict({"tokens": torch.randint(0, 100, (5, 128))}, batch_size=5) @@ -1476,15 +1476,15 @@ def test_load_storage_checkpoint_without_manager(self, mock_controller, tmp_path @pytest.mark.asyncio -async def test_async_get_data_passes_select_items_only_when_set(): - """Custom managers written as get_data(self, metadata) keep serving plain gets.""" +async def test_async_get_data_omits_parser_kwargs_for_old_managers(): + """Plain gets omit parser kwargs so old get_data(self, metadata) managers still work.""" class OldStyleManager: async def get_data(self, metadata): return TensorDict({"x": torch.zeros(metadata.size)}, batch_size=[metadata.size]) client = AsyncTransferQueueClient.__new__(AsyncTransferQueueClient) - client.client_id = "old_manager_client" + client.client_id = "old_mgr_client" client.storage_manager = OldStyleManager() metadata = BatchMeta( global_indexes=[0], @@ -1492,6 +1492,8 @@ async def get_data(self, metadata): field_schema={"x": {"dtype": torch.float32, "shape": (1,), "is_nested": False, "is_non_tensor": False}}, ) - assert "x" in await client.async_get_data(metadata) + result = await client.async_get_data(metadata) + assert "x" in result + with pytest.raises(TypeError): - await client.async_get_data(metadata, select_items=[slice(0, 1)]) + await client.async_get_data(metadata, parser=lambda value, arg: value) diff --git a/tests/test_get_parser.py b/tests/test_get_parser.py new file mode 100644 index 00000000..91b96c86 --- /dev/null +++ b/tests/test_get_parser.py @@ -0,0 +1,322 @@ +# Copyright 2026 The TransferQueue Team +# +# Licensed 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 re + +import numpy as np +import pytest +import torch +from tensordict import NonTensorData, NonTensorStack + +from transfer_queue.utils.get_parser import ( + apply_get_parser, + check_parser_select_fields, + select_list_items, + slice_tensor, + validate_get_parser, +) + + +def test_select_list_items_keeps_requested_order(): + items = [torch.full((2,), float(i)) for i in range(4)] + + selected = select_list_items(items, [3, 0, 3]) + + assert [item[0].item() for item in selected] == [3.0, 0.0, 3.0] + + +def test_select_list_items_empty_selection_returns_empty_list(): + assert select_list_items([1, 2], []) == [] + + +@pytest.mark.parametrize("indices", [[2], [-1]]) +def test_select_list_items_out_of_range_raises(indices): + with pytest.raises(IndexError, match=re.escape(f"indices {indices} are out of range for a list of 2 items")): + select_list_items([1, 2], indices) + + +def test_select_list_items_non_list_value_raises(): + with pytest.raises(TypeError, match="needs a list or tuple value, got Tensor"): + select_list_items(torch.zeros(3), [0]) + + +def test_slice_tensor_prefix_returns_contiguous_view(): + value = torch.arange(12).view(3, 4) + + sliced = slice_tensor(value, 2) + + assert torch.equal(sliced, value[:2]) + assert sliced.data_ptr() == value.data_ptr() + assert sliced.untyped_storage().data_ptr() == value.untyped_storage().data_ptr() + assert sliced.is_contiguous() + + +def test_slice_tensor_accepts_slice_and_tuple_indexes(): + value = torch.arange(24).view(4, 3, 2) + + assert torch.equal(slice_tensor(value, slice(1, 3)), value[1:3]) + assert torch.equal(slice_tensor(value, (slice(None), slice(0, 2))), value[:, :2]) + assert slice_tensor(value, (1, slice(None))).data_ptr() == value[1].data_ptr() + + +def test_slice_tensor_empty_prefix_is_valid_view(): + value = torch.arange(6).view(3, 2) + + sliced = slice_tensor(value, 0) + + assert sliced.shape == (0, 2) + assert sliced.untyped_storage().data_ptr() == value.untyped_storage().data_ptr() + + +@pytest.mark.parametrize("index", [-1, 4]) +def test_slice_tensor_prefix_out_of_range_raises(index): + with pytest.raises(IndexError, match=re.escape(f"prefix length {index} is out of range for dim 0 of size 3")): + slice_tensor(torch.zeros(3, 2), index) + + +def test_slice_tensor_rejects_non_tensor_and_nested(): + with pytest.raises(TypeError, match="needs a torch.Tensor value, got list"): + slice_tensor([1, 2, 3], 1) + nested = torch.nested.as_nested_tensor([torch.ones(2), torch.ones(3)], layout=torch.jagged) + with pytest.raises(TypeError, match="does not support nested tensors"): + slice_tensor(nested, 1) + + +def test_slice_tensor_rejects_bool_and_bad_index_type(): + value = torch.zeros(3) + with pytest.raises(TypeError, match="not bool"): + slice_tensor(value, True) + with pytest.raises(TypeError, match="got str"): + slice_tensor(value, "0:2") + + +def test_slice_tensor_rejects_index_list_unlike_select_list_items(): + """slice_tensor is not advanced indexing; a list of indices is select_list_items-only.""" + value = torch.arange(6).view(3, 2) + with pytest.raises(TypeError, match="does not take a list of indices"): + slice_tensor(value, [0, 2]) + + +def test_slice_tensor_restores_indexing_tuple_after_zmq_roundtrip(): + """msgpack turns indexing tuples into lists; RPC parser_args must still slice multi-dim.""" + from transfer_queue.utils.zmq_utils import ZMQMessage, ZMQRequestType + + index = (slice(None), slice(0, 2)) + msg = ZMQMessage.create( + request_type=ZMQRequestType.GET_DATA, + sender_id="test", + body={"parser_args": [index, (1, slice(None))]}, + ) + decoded_args = ZMQMessage.deserialize(msg.serialize()).body["parser_args"] + assert all(isinstance(arg, list) for arg in decoded_args) + + value = torch.arange(24).view(4, 3, 2) + parsed = apply_get_parser( + {"x": [value, value]}, + slice_tensor, + decoded_args, + global_indexes=[0, 1], + ) + assert torch.equal(parsed["x"][0], value[:, :2]) + assert torch.equal(parsed["x"][1], value[1]) + + # Pure int lists stay rejected even after the same wire path. + int_list_msg = ZMQMessage.create( + request_type=ZMQRequestType.GET_DATA, + sender_id="test", + body={"parser_args": [[0, 2]]}, + ) + int_list_args = ZMQMessage.deserialize(int_list_msg.serialize()).body["parser_args"] + with pytest.raises(ValueError, match="does not take a list of indices"): + apply_get_parser({"x": [value]}, slice_tensor, int_list_args, global_indexes=[0]) + + +def test_apply_get_parser_slice_tensor_keeps_per_key_views(): + field_data = {"input_ids": [torch.arange(4), torch.arange(10, 14)]} + + parsed = apply_get_parser(field_data, slice_tensor, [2, 3], global_indexes=[0, 1]) + + assert torch.equal(parsed["input_ids"][0], torch.tensor([0, 1])) + assert torch.equal(parsed["input_ids"][1], torch.tensor([10, 11, 12])) + assert parsed["input_ids"][0].data_ptr() == field_data["input_ids"][0].data_ptr() + assert parsed["input_ids"][1].data_ptr() == field_data["input_ids"][1].data_ptr() + + +def test_slice_tensor_prefix_encode_sends_only_sliced_nbytes(): + from transfer_queue.utils.serial_utils import MsgpackDecoder, MsgpackEncoder + + value = torch.arange(12, dtype=torch.int64).view(3, 4) + sliced = slice_tensor(value, 2) + assert sliced.is_contiguous() + buffers = MsgpackEncoder().encode(sliced) + + assert buffers[1].nbytes == sliced.nbytes + assert buffers[1].nbytes < value.nbytes + assert torch.equal(MsgpackDecoder().decode(buffers), sliced) + + +def test_slice_tensor_noncontiguous_slice_encodes_correct_values(): + """Column / stride slices are correct; MsgpackEncoder may .contiguous() them first.""" + from transfer_queue.utils.serial_utils import MsgpackDecoder, MsgpackEncoder + + value = torch.arange(12, dtype=torch.int64).view(3, 4) + for index in ((slice(None), slice(0, 2)), slice(None, None, 2)): + sliced = slice_tensor(value, index) + assert not sliced.is_contiguous() + assert torch.equal(MsgpackDecoder().decode(MsgpackEncoder().encode(sliced)), sliced) + + +def test_check_parser_select_fields_rejects_multi_field_without_select_fields(): + with pytest.raises(ValueError, match="omits select_fields but the keys have multiple fields"): + check_parser_select_fields(select_list_items, None, ["images", "input_ids"]) + + +def test_check_parser_select_fields_allows_select_fields_or_single_field(): + check_parser_select_fields(select_list_items, "images", ["images", "input_ids"]) + check_parser_select_fields(select_list_items, ["images"], ["images", "input_ids"]) + check_parser_select_fields(select_list_items, None, ["images"]) + check_parser_select_fields(None, None, ["images", "input_ids"]) + + +def test_validate_get_parser_rejects_args_without_parser(): + with pytest.raises(ValueError, match="parser_args was given without a parser"): + validate_get_parser(None, [[0]], num_keys=1) + + +def test_validate_get_parser_rejects_non_callable(): + with pytest.raises(TypeError, match="parser must be callable, got str"): + validate_get_parser("select_list_items", None, num_keys=1) + + +def test_validate_get_parser_rejects_misaligned_args(): + with pytest.raises(ValueError, match="parser_args has 1 entries but 2 keys are fetched"): + validate_get_parser(select_list_items, [[0]], num_keys=2) + + +def test_validate_get_parser_keeps_plain_int_list_and_slice_args(): + ints = [2, 3] + assert validate_get_parser(slice_tensor, ints, num_keys=2) is ints + slices = [slice(0, 2), (slice(None), slice(0, 1))] + assert validate_get_parser(slice_tensor, slices, num_keys=2) is slices + nested = [[0, 2], [1]] + assert validate_get_parser(select_list_items, nested, num_keys=2) is nested + + +@pytest.mark.parametrize( + "parser_args", + [ + torch.tensor(2), + torch.tensor([2]), + np.int64(2), + np.array(2), + np.array([2]), + ], +) +def test_validate_get_parser_coerces_scalar_length_for_slice_tensor(parser_args): + coerced = validate_get_parser(slice_tensor, [parser_args], num_keys=1) + + assert coerced == [2] + assert all(type(x) is int for x in coerced) + assert torch.equal(slice_tensor(torch.arange(5), coerced[0]), torch.arange(2)) + + +@pytest.mark.parametrize( + "parser_args", + [ + torch.tensor([2, 3]), + np.array([2, 3]), + [torch.tensor(2), np.int64(3)], + [np.array(2), torch.tensor([3])], + ], +) +def test_validate_get_parser_coerces_1d_lengths_to_list_int(parser_args): + coerced = validate_get_parser(slice_tensor, parser_args, num_keys=2) + + assert coerced == [2, 3] + assert all(type(x) is int for x in coerced) + field_data = {"input_ids": [torch.arange(4), torch.arange(10, 14)]} + parsed = apply_get_parser(field_data, slice_tensor, coerced, global_indexes=[0, 1]) + assert torch.equal(parsed["input_ids"][0], torch.tensor([0, 1])) + assert torch.equal(parsed["input_ids"][1], torch.tensor([10, 11, 12])) + + +def test_validate_get_parser_rejects_bad_tensor_shapes_early(): + with pytest.raises(ValueError, match="parser_args tensor/array must be 1-d with length 2"): + validate_get_parser(slice_tensor, torch.tensor([[2, 3]]), num_keys=2) + with pytest.raises(ValueError, match=r"parser_args\[0\] must be a 0-d or single-element integer tensor"): + validate_get_parser(slice_tensor, [torch.zeros(2, 2, dtype=torch.int64)], num_keys=1) + with pytest.raises(ValueError, match=r"parser_args\[0\] must be a 0-d or single-element integer tensor"): + validate_get_parser(slice_tensor, [torch.tensor([[2]])], num_keys=1) + with pytest.raises(TypeError, match=r"parser_args\[0\] must be an integer tensor"): + validate_get_parser(slice_tensor, [torch.tensor(2.0)], num_keys=1) + + +def test_validate_get_parser_coerces_0d_tensor_as_whole_args_for_one_key(): + assert validate_get_parser(slice_tensor, torch.tensor(2), num_keys=1) == [2] + assert validate_get_parser(slice_tensor, np.array(2), num_keys=1) == [2] + + +def test_validate_get_parser_does_not_coerce_select_list_items_args(): + """Coerce is slice_tensor-only; index tensors must stay Sequence-like, not bare int.""" + single = [torch.tensor([2])] + assert validate_get_parser(select_list_items, single, num_keys=1) is single + assert select_list_items(["a", "b", "c"], single[0]) == ["c"] + + for indices in (torch.tensor([0, 2]), np.array([0, 2])): + args = [indices] + assert validate_get_parser(select_list_items, args, num_keys=1) is args + assert select_list_items(["a", "b", "c"], indices) == ["a", "c"] + + +def test_validate_get_parser_custom_callable_skips_length_coerce(): + def custom(value, parser_arg): + return value + + args = [torch.tensor([2])] + assert validate_get_parser(custom, args, num_keys=1) is args + + +def test_apply_get_parser_uses_each_key_arg_on_every_field(): + field_data = {"images": [["a0", "a1", "a2"], ["b0"]], "frames": [["fa0", "fa1", "fa2"], ["fb0", "fb1"]]} + + parsed = apply_get_parser(field_data, select_list_items, [[2, 0], [0]], global_indexes=[5, 9]) + + assert parsed == {"images": [["a2", "a0"], ["b0"]], "frames": [["fa2", "fa0"], ["fb0"]]} + + +def test_apply_get_parser_unwraps_non_tensor_values(): + field_data = {"images": [NonTensorStack("x", "y"), NonTensorData(["z0", "z1"])]} + + parsed = apply_get_parser(field_data, select_list_items, [[1], [0, 1]], global_indexes=[0, 1]) + + assert parsed == {"images": [["y"], ["z0", "z1"]]} + + +def test_apply_get_parser_without_args_passes_none(): + seen_args = [] + + def record_arg(value, parser_arg): + seen_args.append(parser_arg) + return value + + apply_get_parser({"x": [1, 2]}, record_arg, None, global_indexes=[0, 1]) + + assert seen_args == [None, None] + + +def test_apply_get_parser_error_names_field_index_and_arg(): + with pytest.raises(ValueError, match=r"failed on field 'images' of global index 9 with parser_arg \[3\]") as e: + apply_get_parser({"images": [["a0"], ["b0"]]}, select_list_items, [[0], [3]], global_indexes=[5, 9]) + + assert isinstance(e.value.__cause__, IndexError) diff --git a/tests/test_kv_storage_manager.py b/tests/test_kv_storage_manager.py index aed7cda2..5b52f4d2 100644 --- a/tests/test_kv_storage_manager.py +++ b/tests/test_kv_storage_manager.py @@ -519,11 +519,23 @@ def test_put_data_custom_backend_meta_length_mismatch_raises_error(test_data_for @patch.object(KVStorageManager, "_connect_to_controller", lambda self: None) -def test_get_data_with_select_items_raises_before_fetching(test_data_for_put_data): +def test_get_data_with_parser_raises_not_implemented(test_data_for_put_data): mock_storage_client = MagicMock() with patch(f"{STORAGE_CLIENT_FACTORY_PATH}.create", return_value=mock_storage_client): manager = KVStorageManager(controller_info=MagicMock(), config={"client_name": "MockClient"}) - with pytest.raises(NotImplementedError, match="does not support select_items"): - asyncio.run(manager.get_data(test_data_for_put_data["metadata"], select_items=[slice(0, 1)])) + with pytest.raises(NotImplementedError, match="does not support kv_batch_get parsers"): + asyncio.run(manager.get_data(test_data_for_put_data["metadata"], parser=lambda value, arg: value)) + mock_storage_client.get.assert_not_called() + + +@patch.object(KVStorageManager, "_connect_to_controller", lambda self: None) +def test_get_data_parser_args_without_parser_raises(test_data_for_put_data): + """KV backends must not silently ignore parser_args when parser is None.""" + mock_storage_client = MagicMock() + with patch(f"{STORAGE_CLIENT_FACTORY_PATH}.create", return_value=mock_storage_client): + manager = KVStorageManager(controller_info=MagicMock(), config={"client_name": "MockClient"}) + + with pytest.raises(ValueError, match="parser_args was given without a parser"): + asyncio.run(manager.get_data(test_data_for_put_data["metadata"], parser_args=[[0]])) mock_storage_client.get.assert_not_called() diff --git a/tests/test_simple_storage_unit.py b/tests/test_simple_storage_unit.py index 230e1c0b..fe6e8a6e 100644 --- a/tests/test_simple_storage_unit.py +++ b/tests/test_simple_storage_unit.py @@ -46,10 +46,8 @@ SimpleStorageUnit, SSDFileStore, StorageKeyNotFoundError, - _select_items, _SSDValueRef, ) -from transfer_queue.utils.serial_utils import MsgpackDecoder, MsgpackEncoder from transfer_queue.utils.zmq_utils import ( STORAGE_MANAGER_IDENTITY_PREFIX, ZMQMessage, @@ -83,10 +81,12 @@ def send_put(self, client_id, global_indexes, field_data, data_parser=None): self.socket.send_multipart(msg.serialize()) return ZMQMessage.deserialize(self.socket.recv_multipart(copy=False)) - def send_get(self, client_id, global_indexes, fields, select_items=None): + def send_get(self, client_id, global_indexes, fields, parser=None, parser_args=None): body = {"global_indexes": global_indexes, "fields": fields} - if select_items is not None: - body["select_items"] = select_items + if parser is not None: + body["parser"] = parser + if parser_args is not None: + body["parser_args"] = parser_args msg = ZMQMessage.create( request_type=ZMQRequestType.GET_DATA, sender_id=f"mock_client_{client_id}", @@ -1177,35 +1177,22 @@ def test_undecodable_request_answered_and_worker_survives(storage_setup): client.close() -def test_get_select_items_returns_selected_items_and_keeps_stored_values(storage_setup): +def test_unserializable_get_response_answered_and_worker_survives(storage_setup): + """A get parser that returns a non-encodable value must not kill the sole worker.""" _, put_get_address, storage_ip = storage_setup client = MockStorageClient(put_get_address, storage_ip) - tokens = [torch.arange(4), torch.arange(10, 16)] - images = [["a0", "a1", "a2"], ["b0", "b1"]] - client.send_put(0, [0, 1], {"tokens": tokens, "images": images}) - response = client.send_get(0, [0, 1], ["tokens", "images"], select_items=[slice(0, 2), [1, 0, 1]]) - - assert response.request_type == ZMQRequestType.GET_DATA_RESPONSE - data = response.body["data"] - torch.testing.assert_close(data["tokens"][0], torch.tensor([0, 1])) - torch.testing.assert_close(data["tokens"][1], torch.tensor([11, 10, 11])) - assert data["images"] == [["a0", "a1"], ["b1", "b0", "b1"]] - full = client.send_get(0, [0, 1], ["tokens", "images"]).body["data"] - torch.testing.assert_close(full["tokens"][1], tokens[1]) - assert full["images"] == images - - response = client.send_get(0, [0, 1], ["images"], select_items=[[0], [5]]) - assert response.request_type == ZMQRequestType.GET_ERROR - assert "out of range" in response.body["message"] - client.close() + response = client.send_put(0, [0], {"val": [torch.tensor([1.0])]}) + assert response.request_type == ZMQRequestType.PUT_DATA_RESPONSE + def generator_parser(value, parser_arg): + return (x for x in [1, 2, 3]) -def test_select_items_slice_sends_only_the_selected_bytes(): - value = torch.arange(12, dtype=torch.int64).view(3, 4) - selected = _select_items(value, slice(0, 2)) + response = client.send_get(0, [0], ["val"], parser=generator_parser) + assert response.request_type == ZMQRequestType.GET_ERROR + assert "failed to serialize response" in response.body["message"] - buffers = MsgpackEncoder().encode(selected) - assert selected.data_ptr() == value.data_ptr() - assert buffers[1].nbytes == selected.nbytes < value.nbytes - assert torch.equal(MsgpackDecoder().decode(buffers), value[:2]) + response = client.send_get(0, [0], ["val"]) + assert response.request_type == ZMQRequestType.GET_DATA_RESPONSE + torch.testing.assert_close(response.body["data"]["val"][0], torch.tensor([1.0])) + client.close() diff --git a/transfer_queue/__init__.py b/transfer_queue/__init__.py index 6c514b2b..3554c9df 100644 --- a/transfer_queue/__init__.py +++ b/transfer_queue/__init__.py @@ -59,6 +59,7 @@ from .sampler.seqlen_balanced_sampler import SeqlenBalancedSampler from .sampler.sequential_sampler import SequentialSampler from .storage import StorageKeyNotFoundError +from .utils.get_parser import GetParser, select_list_items, slice_tensor __all__ = ( [ @@ -83,6 +84,9 @@ "async_kv_clear", "async_kv_update", "KVBatchMeta", + "GetParser", + "select_list_items", + "slice_tensor", ] + [ # KV key locks: process-local and cluster-wide diff --git a/transfer_queue/client.py b/transfer_queue/client.py index c70ee187..c5c4913c 100644 --- a/transfer_queue/client.py +++ b/transfer_queue/client.py @@ -28,6 +28,7 @@ from transfer_queue.metadata import BatchMeta from transfer_queue.storage import StorageManagerFactory from transfer_queue.utils.common import limit_pytorch_auto_parallel_threads +from transfer_queue.utils.get_parser import GetParser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.zmq_utils import ( TQ_SOCKET_POOL_SIZE, @@ -523,13 +524,19 @@ async def async_update( field_schema = await self.storage_manager.update_data(metadata, values, merge_fn) return metadata.apply_field_schema(field_schema) - async def async_get_data(self, metadata: BatchMeta, select_items: Sequence[Any] | None = None) -> TensorDict: + async def async_get_data( + self, + metadata: BatchMeta, + parser: GetParser | None = None, + parser_args: Sequence[Any] | None = None, + ) -> TensorDict: """Asynchronously fetch data from storage units and organize into TensorDict. Args: metadata: Batch metadata containing data location information and global indexes - select_items: Optional per-sample selection inside each value, aligned with - ``metadata.global_indexes``. See ``transfer_queue.kv_batch_get``. + parser: Optional get parser the storage units apply to every requested value before replying. + See ``transfer_queue.kv_batch_get``; only supported by SimpleStorage. + parser_args: Optional per-sample parser arguments, aligned with ``metadata.global_indexes``. Returns: TensorDict containing: @@ -563,11 +570,12 @@ async def async_get_data(self, metadata: BatchMeta, select_items: Sequence[Any] with limit_pytorch_auto_parallel_threads( target_num_threads=TQ_NUM_THREADS, info=f"[{self.client_id}] async_get_data" ): - # Passed only when set, so custom managers with get_data(self, metadata) keep working. - if select_items is None: - results = await self.storage_manager.get_data(metadata) + # Only pass parser kwargs when a parser is used so old custom managers + # with get_data(self, metadata) keep working for plain gets. + if parser is not None: + results = await self.storage_manager.get_data(metadata, parser=parser, parser_args=parser_args) else: - results = await self.storage_manager.get_data(metadata, select_items=select_items) + results = await self.storage_manager.get_data(metadata) logger.debug(f"[{self.client_id}]: get_data with {metadata.size} samples successfully.") @@ -1652,12 +1660,19 @@ def update( """ return self._update(metadata, values, merge_fn) - def get_data(self, metadata: BatchMeta, select_items: Sequence[Any] | None = None) -> TensorDict: + def get_data( + self, + metadata: BatchMeta, + parser: GetParser | None = None, + parser_args: Sequence[Any] | None = None, + ) -> TensorDict: """Synchronously fetch data from storage units and organize into TensorDict. Args: metadata: Batch metadata containing data location information and global indexes - select_items: Optional per-sample selection inside each value. See ``async_get_data``. + parser: Optional get parser the storage units apply to every requested value before replying. + See ``transfer_queue.kv_batch_get``; only supported by SimpleStorage. + parser_args: Optional per-sample parser arguments, aligned with ``metadata.global_indexes``. Returns: TensorDict containing: @@ -1675,7 +1690,7 @@ def get_data(self, metadata: BatchMeta, select_items: Sequence[Any] | None = Non >>> print(batch) >>> # TensorDict with fields "prompts", "attention_mask", and sample order matching metadata global_indexes """ - return self._get_data(metadata=metadata, select_items=select_items) + return self._get_data(metadata=metadata, parser=parser, parser_args=parser_args) def clear_partition(self, partition_id: str): """Synchronously clear the whole partition from all storage units and the controller. diff --git a/transfer_queue/interface.py b/transfer_queue/interface.py index 83771a25..a131a87e 100644 --- a/transfer_queue/interface.py +++ b/transfer_queue/interface.py @@ -54,6 +54,7 @@ from transfer_queue.sampler import BaseSampler from transfer_queue.storage.bootstrap import StorageBootstrapProvider from transfer_queue.storage.managers.simple_storage_manager import AsyncSimpleStorageManager +from transfer_queue.utils.get_parser import GetParser, check_parser_select_fields, validate_get_parser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.yuanrong_utils import cleanup_yuanrong_resources from transfer_queue.utils.zmq_utils import process_zmq_server_info @@ -484,11 +485,7 @@ def kv_batch_put( ) -def kv_batch_get_by_meta( - meta: KVBatchMeta, - select_fields: list[str] | str | None = None, - select_items: Sequence[Any] | None = None, -) -> TensorDict: +def kv_batch_get_by_meta(meta: KVBatchMeta, select_fields: list[str] | str | None = None) -> TensorDict: """Get data from TransferQueue using KVBatchMeta. This is a convenience method for retrieving data using KVBatchMeta returned @@ -502,7 +499,6 @@ def kv_batch_get_by_meta( recorded in the given KVBatchMeta. If None, uses all fields from meta.fields. Can be a single field name (str) or a list of field names. - select_items: Optional per-key selection inside each value. See ``kv_batch_get``. Returns: TensorDict with the requested data @@ -526,16 +522,15 @@ def kv_batch_get_by_meta( >>> data = tq.kv_batch_get_by_meta(meta) """ tq_client = _maybe_create_tq_client() - return tq_client._run_coroutine( - async_kv_batch_get_by_meta(meta=meta, select_fields=select_fields, select_items=select_items) - ) + return tq_client._run_coroutine(async_kv_batch_get_by_meta(meta=meta, select_fields=select_fields)) def kv_batch_get( keys: list[str] | str, partition_id: str, select_fields: list[str] | str | None = None, - select_items: Sequence[Any] | None = None, + parser: GetParser | None = None, + parser_args: Sequence[Any] | None = None, ) -> TensorDict: """Get data from TransferQueue using user-specified keys. @@ -544,14 +539,21 @@ def kv_batch_get( Args: keys: Single key or list of keys to retrieve partition_id: Partition containing the keys - select_fields: Optional field(s) to retrieve. If None, retrieves all fields. - Required with ``select_items``. - select_items: Optional per-key selection along dim 0 of every selected value, - aligned with ``keys``: a ``slice`` (a view for tensors) or a list of indices - (reorder and repeats allowed; a copy for tensors). The storage units apply it - before replying, so only the selected items cross the network; SSD-offloaded - values are still read in full. SimpleStorage only; KV backends raise - ``NotImplementedError``. + select_fields: Optional field(s) to retrieve. If None, retrieves all fields. With a ``parser``, pass + ``select_fields`` limited to compatible fields (required when keys have more than one field). + parser: Optional ``parser(value, parser_arg)`` each SimpleStorage unit applies to every selected (key, field) + after loading/decoding the stored value and before replying, e.g. ``transfer_queue.select_list_items``. + Only the parsed subset is re-serialized on the wire (bandwidth); with Hybrid/SSD offload the full + stored blob may still be read from disk first — parsers do not cut disk I/O by themselves. One get + uses one parser and one per-key ``parser_arg`` for every selected field; heterogeneous field parsing + needs separate gets. Pair the parser with ``select_fields`` for compatible fields (omit only when + the keys have a single field). It runs synchronously on the storage unit worker, so keep it light + and side-effect free. Sent by cloudpickle, so a module-level parser must be importable on the + storage units. KV backends raise NotImplementedError. + parser_args: Optional per-key ``parser_arg`` values aligned with ``keys``; None passes None for every key. + For ``slice_tensor`` only, integer ``torch.Tensor`` / ``numpy`` lengths (e.g. ``attention_mask.sum(-1)``) + are coerced to ``int`` / ``list[int]`` before send so storage units never see a Tensor as a slice index. + Other parsers (``select_list_items``, custom callables) leave ``parser_args`` unchanged. Returns: TensorDict with the requested data @@ -559,7 +561,9 @@ def kv_batch_get( Raises: ValueError: If keys or partition are not found ValueError: If empty fields exist in any key (sample) - ValueError: If ``select_items`` is given without ``select_fields``, or some keys are missing + ValueError: If a parser is given and some keys are missing + ValueError: If a parser is given without ``select_fields`` and the keys have multiple fields + RuntimeError: If the parser fails on a storage unit; the message names the field, global index and parser_arg Example: >>> import transfer_queue as tq @@ -572,17 +576,48 @@ def kv_batch_get( ... partition_id="train", ... select_fields="input_ids" ... ) - >>> # Trim each key's input_ids to its length and take some images of each key - >>> data = tq.kv_batch_get(keys, "train", select_fields="input_ids", - ... select_items=[slice(0, n) for n in lengths.tolist()]) - >>> data = tq.kv_batch_get(keys, "train", select_fields="images", select_items=[[0, 2], [1]]) + >>> # List fields: select_list_items takes per-key Sequence[int] indices (reorder/repeat OK). + >>> # select_fields keeps the parser off tensor fields such as input_ids (use another get for those). + >>> data = tq.kv_batch_get( + ... keys=["sample_1", "sample_2"], + ... partition_id="train", + ... select_fields="images", + ... parser=tq.select_list_items, + ... parser_args=[[0, 2], [1]], + ... ) + >>> # Tensor fields: slice_tensor takes per-key int (dim-0 prefix), slice, or indexing tuple — + >>> # not a list of indices like select_list_items. Contiguous dim-0 prefixes avoid encode materialization. + >>> data = tq.kv_batch_get( + ... keys=["sample_1", "sample_2"], + ... partition_id="train", + ... select_fields="input_ids", + ... parser=tq.slice_tensor, + ... parser_args=[3, 5], + ... ) """ + keys = [keys] if isinstance(keys, str) else keys + parser_args = validate_get_parser(parser, parser_args, len(keys)) tq_client = _maybe_create_tq_client() return tq_client._run_coroutine( - async_kv_batch_get(keys=keys, partition_id=partition_id, select_fields=select_fields, select_items=select_items) + async_kv_batch_get( + keys=keys, + partition_id=partition_id, + select_fields=select_fields, + parser=parser, + parser_args=parser_args, + ) ) +def _check_all_keys_found(num_found: int, keys: list[str], partition_id: str, parser: GetParser | None) -> None: + # NOTE(neowywang): a missing key is dropped from the meta, which would shift parser_args onto other keys. + if parser is not None and num_found != len(keys): + raise ValueError( + f"only {num_found} of {len(keys)} keys were found in partition {partition_id!r}; a kv_batch_get " + "with a parser needs every key, since parser_args are aligned with keys" + ) + + def kv_list(partition_id: str | None = None) -> dict[str, dict[str, Any]]: """List all keys and their metadata in one or all partitions. @@ -905,11 +940,7 @@ async def async_kv_batch_put( ) -async def async_kv_batch_get_by_meta( - meta: KVBatchMeta, - select_fields: list[str] | str | None = None, - select_items: Sequence[Any] | None = None, -) -> TensorDict: +async def async_kv_batch_get_by_meta(meta: KVBatchMeta, select_fields: list[str] | str | None = None) -> TensorDict: """Asynchronously get data from TransferQueue using KVBatchMeta. This is a convenience method for retrieving data using KVBatchMeta returned @@ -923,7 +954,6 @@ async def async_kv_batch_get_by_meta( recorded in the given KVBatchMeta. If None, uses all fields from meta.fields. Can be a single field name (str) or a list of field names. - select_items: Optional per-key selection inside each value. See ``kv_batch_get``. Returns: TensorDict with the requested data @@ -964,16 +994,15 @@ async def async_kv_batch_get_by_meta( ) else: fields_to_fetch = meta.fields - return await async_kv_batch_get( - keys=meta.keys, partition_id=meta.partition_id, select_fields=fields_to_fetch, select_items=select_items - ) + return await async_kv_batch_get(keys=meta.keys, partition_id=meta.partition_id, select_fields=fields_to_fetch) async def async_kv_batch_get( keys: list[str] | str, partition_id: str, select_fields: list[str] | str | None = None, - select_items: Sequence[Any] | None = None, + parser: GetParser | None = None, + parser_args: Sequence[Any] | None = None, ) -> TensorDict: """Asynchronously get data from TransferQueue using user-specified keys. @@ -982,9 +1011,14 @@ async def async_kv_batch_get( Args: keys: Single key or list of keys to retrieve partition_id: Partition containing the keys - select_fields: Optional field(s) to retrieve. If None, retrieves all fields. - Required with ``select_items``. - select_items: Optional per-key selection inside each value. See ``kv_batch_get``. + select_fields: Optional field(s) to retrieve. If None, retrieves all fields. With a ``parser``, pass + ``select_fields`` limited to compatible fields (required when keys have more than one field). + parser: Optional get parser each SimpleStorage unit applies after load/decode and before replying; + wire size shrinks to the parsed subset, but Hybrid/SSD may still read the full stored blob first. + One get uses one parser for every selected field. Pair with ``select_fields``; omit only when keys + have a single field. Heterogeneous field parsing needs separate gets. See ``kv_batch_get``. + parser_args: Optional per-key ``parser_arg`` values aligned with ``keys``; None passes None for every key. + Length coerce applies to ``slice_tensor`` only, same as ``kv_batch_get``. Returns: TensorDict with the requested data @@ -992,7 +1026,9 @@ async def async_kv_batch_get( Raises: ValueError: If keys or partition are not found ValueError: If empty fields exist in any key (sample) - ValueError: If ``select_items`` is given without ``select_fields``, or some keys are missing + ValueError: If a parser is given and some keys are missing + ValueError: If a parser is given without ``select_fields`` and the keys have multiple fields + RuntimeError: If the parser fails on a storage unit; the message names the field, global index and parser_arg Example: >>> import transfer_queue as tq @@ -1006,18 +1042,17 @@ async def async_kv_batch_get( ... select_fields="input_ids" ... ) """ - if select_items is not None and select_fields is None: - raise ValueError("select_items needs select_fields, since it applies to every selected field") + keys = [keys] if isinstance(keys, str) else keys + parser_args = validate_get_parser(parser, parser_args, len(keys)) tq_client = _maybe_create_tq_client() batch_meta = await tq_client.async_kv_retrieve_meta(keys=keys, partition_id=partition_id, create=False) if batch_meta.size == 0: raise ValueError("keys or partition were not found!") - # Missing keys are dropped from the meta, which would shift select_items onto other keys. - num_keys = 1 if isinstance(keys, str) else len(keys) - if select_items is not None and batch_meta.size != num_keys: - raise ValueError(f"only {batch_meta.size} of {num_keys} keys were found; select_items needs every key") + _check_all_keys_found(batch_meta.size, keys, partition_id, parser) + # Field set is known only after meta retrieve; fail before get_data when parser would hit all fields. + check_parser_select_fields(parser, select_fields, batch_meta.field_names) if select_fields is not None: if isinstance(select_fields, str): @@ -1029,7 +1064,7 @@ async def async_kv_batch_get( if not batch_meta.is_ready: raise ValueError("Some fields are not ready in all the requested keys!") - data = await tq_client.async_get_data(batch_meta, select_items=select_items) + data = await tq_client.async_get_data(batch_meta, parser=parser, parser_args=parser_args) return data diff --git a/transfer_queue/storage/managers/base.py b/transfer_queue/storage/managers/base.py index 9efd24bb..b265a4a4 100644 --- a/transfer_queue/storage/managers/base.py +++ b/transfer_queue/storage/managers/base.py @@ -36,6 +36,7 @@ from transfer_queue.metadata import BatchMeta, extract_field_schema from transfer_queue.storage.clients.base import StorageClientFactory +from transfer_queue.utils.get_parser import GetParser, validate_get_parser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.zmq_utils import ( STORAGE_MANAGER_IDENTITY_PREFIX, @@ -335,14 +336,19 @@ async def put_data( raise NotImplementedError("Subclasses must implement put_data") @abstractmethod - async def get_data(self, metadata: BatchMeta, select_items: Sequence[Any] | None = None) -> TensorDict: + async def get_data( + self, + metadata: BatchMeta, + parser: GetParser | None = None, + parser_args: Sequence[Any] | None = None, + ) -> TensorDict: """ Get data from the storage backend. Args: metadata: BatchMeta of the data to be retrieved from the storage. - select_items: Optional per-sample selection inside each value, aligned with - ``metadata.global_indexes``. See ``transfer_queue.kv_batch_get``. + parser: Optional get parser applied to every requested value where it is stored, before sending. + parser_args: Optional per-sample parser arguments, aligned with ``metadata.global_indexes``. Returns: TensorDict containing the data retrieved from the storage. @@ -826,17 +832,24 @@ async def put_data( per_field_custom_backend_meta, ) - async def get_data(self, metadata: BatchMeta, select_items: Sequence[Any] | None = None) -> TensorDict: + async def get_data( + self, + metadata: BatchMeta, + parser: GetParser | None = None, + parser_args: Sequence[Any] | None = None, + ) -> TensorDict: """ Retrieve tensor data from the backend storage. Fetches tensors using the provided metadata, reconstructs them with the correct shapes and dtypes, and merge them as a TensorDict according to metadata. """ - if select_items is not None: + validate_get_parser(parser, parser_args, metadata.size) + if parser is not None: raise NotImplementedError( - f"{type(self).__name__} does not support select_items: KV backends return whole values. " - "Use the SimpleStorage backend, or select on the caller side" + f"{type(self).__name__} does not support kv_batch_get parsers: KV backends return whole (key, field) " + "values and cannot run a parser where the data is stored. Use the SimpleStorage backend, or fetch " + "without parser= and select the needed part on the caller side" ) if not metadata.field_names: logger.warning("Attempted to get data, but metadata contains no fields.") diff --git a/transfer_queue/storage/managers/simple_storage_manager.py b/transfer_queue/storage/managers/simple_storage_manager.py index 592540e8..badaed0b 100644 --- a/transfer_queue/storage/managers/simple_storage_manager.py +++ b/transfer_queue/storage/managers/simple_storage_manager.py @@ -14,7 +14,6 @@ # limitations under the License. import asyncio -import operator import os import time import warnings @@ -25,7 +24,6 @@ from pathlib import Path from typing import Any, Callable, NamedTuple -import numpy as np import torch import zmq from omegaconf import DictConfig @@ -38,6 +36,7 @@ StorageKeyNotFoundError, ) from transfer_queue.utils.common import log_heavy_operation +from transfer_queue.utils.get_parser import GetParser, validate_get_parser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.zmq_utils import ( TQ_SOCKET_POOL_SIZE, @@ -159,28 +158,6 @@ def _build_update_field_schema( ) -def _normalize_select_items(select_items: Sequence[Any], num_samples: int) -> list[slice | list[int]]: - """Turn per-sample selections into plain slices and int lists that round-trip to the units.""" - if isinstance(select_items, torch.Tensor | np.ndarray): - select_items = select_items.tolist() - if not isinstance(select_items, Sequence) or isinstance(select_items, str | bytes): - raise TypeError(f"select_items must be a sequence with one entry per key, got {type(select_items).__name__}") - if len(select_items) != num_samples: - raise ValueError(f"select_items has {len(select_items)} entries for {num_samples} keys; give one per key") - normalized: list[slice | list[int]] = [] - for item in select_items: - if isinstance(item, torch.Tensor | np.ndarray): - item = item.tolist() - if isinstance(item, slice): - bounds = (item.start, item.stop, item.step) - normalized.append(slice(*(None if b is None else operator.index(b) for b in bounds))) - elif isinstance(item, Sequence) and not isinstance(item, str | bytes): - normalized.append([operator.index(i) for i in item]) - else: - raise TypeError(f"each select_items entry must be a slice or a list of indices, got {type(item).__name__}") - return normalized - - class RoutingGroup(NamedTuple): """Routing result for a single storage unit.""" @@ -803,7 +780,12 @@ def _pack_field_values(values: list) -> torch.Tensor | NonTensorStack: # Mixed tensor + None — cannot create nested tensor, fall through to NonTensorStack return NonTensorStack(*values) - async def get_data(self, metadata: BatchMeta, select_items: Sequence[Any] | None = None) -> TensorDict: + async def get_data( + self, + metadata: BatchMeta, + parser: GetParser | None = None, + parser_args: Sequence[Any] | None = None, + ) -> TensorDict: """ Retrieve data from remote StorageUnit based on metadata. @@ -811,8 +793,9 @@ async def get_data(self, metadata: BatchMeta, select_items: Sequence[Any] | None Args: metadata: BatchMeta that contains metadata for data retrieval. - select_items: Optional per-sample selection along dim 0 of each value, aligned with - ``metadata.global_indexes``; each unit applies it before replying. + parser: Optional ``parser(value, parser_arg)`` each SU applies after load/decode; only the parsed + subset is serialized on the wire (Hybrid/SSD may still read the full blob first). + parser_args: Optional per-sample parser arguments, aligned with ``metadata.global_indexes``. Returns: TensorDict containing the retrieved data. @@ -822,8 +805,7 @@ async def get_data(self, metadata: BatchMeta, select_items: Sequence[Any] | None if metadata.size == 0: return TensorDict({}, batch_size=0) - if select_items is not None: - select_items = _normalize_select_items(select_items, metadata.size) + parser_args = validate_get_parser(parser, parser_args, metadata.size) routing = self._group_by_hash(metadata.global_indexes) @@ -837,7 +819,8 @@ async def get_data(self, metadata: BatchMeta, select_items: Sequence[Any] | None group.global_indexes, metadata.field_names, target_storage_unit=su_id, - select_items=None if select_items is None else [select_items[p] for p in group.batch_positions], + parser=parser, + parser_args=None if parser_args is None else [parser_args[pos] for pos in group.batch_positions], ), ) for su_id, group in routing.items() @@ -876,13 +859,14 @@ async def _get_from_single_storage_unit( global_indexes: list[int], fields: list[str], target_storage_unit: str, - select_items: list[slice | list[int]] | None = None, + parser: GetParser | None = None, + parser_args: list[Any] | None = None, socket: zmq.Socket = None, ): """Get data from a single SU by global index keys.""" - body: dict[str, Any] = {"global_indexes": global_indexes, "fields": fields} - if select_items is not None: - body["select_items"] = select_items + body = {"global_indexes": global_indexes, "fields": fields} + if parser is not None: + body.update(parser=parser, parser_args=parser_args) request_msg = ZMQMessage.create( request_type=ZMQRequestType.GET_DATA, # type: ignore[arg-type] sender_id=self.storage_manager_id, @@ -895,6 +879,11 @@ async def _get_from_single_storage_unit( response_msg = ZMQMessage.deserialize(messages) if response_msg.request_type == ZMQRequestType.GET_DATA_RESPONSE: + if parser is not None and not response_msg.body.get("parsed", False): + raise RuntimeError( + f"storage unit {target_storage_unit} replied without applying the kv_batch_get parser; it " + "runs a TransferQueue older than get parsers, so restart the storage units on this version" + ) storage_unit_data = response_msg.body["data"] return fields, storage_unit_data else: diff --git a/transfer_queue/storage/simple_storage.py b/transfer_queue/storage/simple_storage.py index f7927cc8..53feed21 100644 --- a/transfer_queue/storage/simple_storage.py +++ b/transfer_queue/storage/simple_storage.py @@ -51,6 +51,7 @@ from transfer_queue.utils import compact_pickle from transfer_queue.utils.common import limit_pytorch_auto_parallel_threads, log_heavy_operation from transfer_queue.utils.enum_utils import Role +from transfer_queue.utils.get_parser import apply_get_parser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.perf_utils import IntervalPerfMonitor from transfer_queue.utils.zmq_utils import ( @@ -112,16 +113,6 @@ class _SSDValueRef: TQ_ACCEPT_PROBE_INTERVAL = float(os.environ.get("TQ_ACCEPT_PROBE_INTERVAL", 0)) -def _select_items(value: Any, items: slice | list[int]) -> Any: - """Select along dim 0 without modifying the stored value. - - A slice of a tensor is a view, an index list of a tensor is a copy, and a list value yields a new list. - """ - if isinstance(items, slice) or isinstance(value, torch.Tensor): - return value[items] - return [value[i] for i in items] - - def _describe_stored_values(values: list) -> dict[str, Any]: """Describe per-sample stored values for the manager. @@ -1159,17 +1150,39 @@ def _process_one_worker_request(self, worker_socket: zmq.Socket, monitor: Any) - }, ) - response_frames = response_msg.serialize() - if operation == ZMQRequestType.GET_DATA: # type: ignore[arg-type] - # This end serializes the get response, so its frames give the true wire size. - log_heavy_operation( - self.storage_unit_id, - "get", - time.perf_counter() - started, - sum(frame_nbytes(frame) or 0 for frame in response_frames), - f"samples={len(request_msg.body.get('global_indexes', []))} " - f"fields={list(request_msg.body.get('fields', []))}", + # Serialize is outside the handler try: a success body can still fail encode + # (e.g. get parser returns a generator). Contain that here so the sole worker lives. + try: + response_frames = response_msg.serialize() + except Exception as e: + logger.error( + f"[{self.storage_unit_id}]: failed to serialize response for {operation} " + f"from sender={request_msg.sender_id}: {type(e).__name__}: {e}" + ) + error_type = ( + ZMQRequestType.GET_ERROR + if operation == ZMQRequestType.GET_DATA # type: ignore[arg-type] + else ZMQRequestType.PUT_GET_ERROR ) + response_frames = ZMQMessage.create( + request_type=error_type, # type: ignore[arg-type] + sender_id=self.storage_unit_id, + body={ + "message": f"{self.storage_unit_id}: failed to serialize response " + f"for {operation}: {type(e).__name__}: {e}" + }, + ).serialize() + else: + if operation == ZMQRequestType.GET_DATA: # type: ignore[arg-type] + # This end serializes the get response, so its frames give the true wire size. + log_heavy_operation( + self.storage_unit_id, + "get", + time.perf_counter() - started, + sum(frame_nbytes(frame) or 0 for frame in response_frames), + f"samples={len(request_msg.body.get('global_indexes', []))} " + f"fields={list(request_msg.body.get('fields', []))}", + ) worker_socket.send_multipart([identity] + response_frames, copy=False) def _handle_put(self, data_parts: ZMQMessage) -> ZMQMessage: @@ -1299,24 +1312,26 @@ def _handle_get(self, data_parts: ZMQMessage) -> ZMQMessage: try: fields = data_parts.body["fields"] global_indexes = data_parts.body["global_indexes"] + parser = data_parts.body.get("parser") with limit_pytorch_auto_parallel_threads( target_num_threads=TQ_NUM_THREADS, info=f"[{self.storage_unit_id}] _handle_get" ): + # get_data may read/decode the full SSD-backed blob; the parser only shrinks the reply wire size. result_data = self.storage_data.get_data(fields, global_indexes) - select_items = data_parts.body.get("select_items") - if select_items is not None: - result_data = { - field: [_select_items(value, items) for value, items in zip(values, select_items, strict=True)] - for field, values in result_data.items() - } + if parser is not None: + result_data = apply_get_parser( + result_data, parser, data_parts.body.get("parser_args"), global_indexes + ) + response_body: dict[str, Any] = {"data": result_data} + if parser is not None: + # NOTE(neowywang): lets the manager tell a parsed reply from an older unit that ignored the parser. + response_body["parsed"] = True response_msg = ZMQMessage.create( request_type=ZMQRequestType.GET_DATA_RESPONSE, # type: ignore[arg-type] sender_id=self.storage_unit_id, - body={ - "data": result_data, - }, + body=response_body, ) except Exception as e: key_not_found = isinstance(e, StorageKeyNotFoundError) diff --git a/transfer_queue/utils/get_parser.py b/transfer_queue/utils/get_parser.py new file mode 100644 index 00000000..9f00d16c --- /dev/null +++ b/transfer_queue/utils/get_parser.py @@ -0,0 +1,256 @@ +# Copyright 2026 The TransferQueue Team +# +# Licensed 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. + +"""Get parsers: callables a SimpleStorage unit applies to stored values before replying to ``kv_batch_get``. + +A parser runs after the storage unit has loaded and decoded the stored value. It trims that value +to the part the caller needs; only the parsed subset is re-serialized and sent on the wire +(bandwidth). With Hybrid/SSD offload (and similar), the full stored blob may still be read from +disk and decoded before parsing — ``parser_args`` alone do not reduce disk I/O. + +One get uses one ``parser`` and one per-key ``parser_arg`` for every selected field. Heterogeneous +field parsing (different parsers or args per field) needs separate gets. Pair a parser with +``select_fields`` limited to compatible fields; omitting ``select_fields`` is only valid when the +keys have a single field.""" + +import reprlib +from collections.abc import Callable, Sequence +from typing import Any + +import numpy as np +import torch +from tensordict import NonTensorData, NonTensorStack + +GetParser = Callable[[Any, Any], Any] +"""``parser(value, parser_arg) -> parsed value``, called once per selected (key, field).""" + + +def select_list_items(value: Sequence[Any], indices: Sequence[int]) -> list[Any]: + """Return the items of a list-valued field at ``indices`` (a ``Sequence[int]``), in that order. + + Reorder and repeats are allowed (e.g. ``[3, 0, 3]``). An index outside ``[0, len(value))`` + raises ``IndexError`` instead of wrapping around. This is not the same API as ``slice_tensor``, + which takes a dim-0 prefix length, ``slice``, or indexing ``tuple`` — not a list of indices. + """ + if not isinstance(value, list | tuple): + raise TypeError(f"select_list_items needs a list or tuple value, got {type(value).__name__}") + out_of_range = [index for index in indices if not 0 <= index < len(value)] + if out_of_range: + raise IndexError(f"indices {out_of_range} are out of range for a list of {len(value)} items") + return [value[index] for index in indices] + + +def _indexing_tuple_from_list(index: list) -> tuple[Any, ...] | None: + """Restore a multi-dim indexing tuple that msgpack decoded as a list, or return None. + + Untyped msgpack turns ``(slice(None), slice(0, 2))`` into ``[slice(None), slice(0, 2)]``. + Accept that shape so RPC ``parser_args`` still work; keep rejecting a pure ``list[int]`` + (that is ``select_list_items`` advanced-index style, not ``value[i, j, ...]``). + """ + if not index: + return None + has_slice = False + for item in index: + if isinstance(item, bool): + return None + if isinstance(item, slice): + has_slice = True + elif isinstance(item, int) or item is None or item is Ellipsis: + continue + else: + return None + return tuple(index) if has_slice else None + + +def slice_tensor(value: torch.Tensor, index: int | slice | tuple[Any, ...]) -> torch.Tensor: + """Return ``value`` sliced by ``index`` so only that logical content is serialized and sent on get. + + ``index`` is an ``int`` dim-0 prefix length (``value[:index]``), a ``slice``, or an indexing + ``tuple`` applied as ``value[index]``. It is not a list of indices like ``select_list_items``; + pass ``[0, 2]`` and you get ``TypeError``, not advanced indexing. After RPC, msgpack may + deliver an indexing tuple as a list that still contains at least one ``slice``; that form is + restored to a tuple. Wire size follows the sliced logical content in all cases. + + Encode without ``.contiguous()`` materialization only when the sliced result ``is_contiguous()`` + (typical: dim-0 prefix ``[:n]`` of a C-contiguous tensor). Non-contiguous slices such as + ``[:, :k]`` or ``[::2]`` still round-trip correctly, but ``MsgpackEncoder`` may call + ``.contiguous()`` before extracting the buffer. + """ + if not isinstance(value, torch.Tensor): + raise TypeError(f"slice_tensor needs a torch.Tensor value, got {type(value).__name__}") + if value.is_nested: + raise TypeError("slice_tensor does not support nested tensors; pass a regular tensor field") + if isinstance(index, bool): + raise TypeError("slice_tensor index must be an int stop, a slice, or a tuple of slices/ints, not bool") + if isinstance(index, int): + if value.dim() == 0: + raise IndexError("slice_tensor cannot take a prefix length of a 0-dim tensor") + if not 0 <= index <= value.size(0): + raise IndexError(f"prefix length {index} is out of range for dim 0 of size {value.size(0)}") + return value[:index] + if isinstance(index, list): + # msgpack loses tuple vs list; only lists with a slice are treated as indexing tuples. + restored = _indexing_tuple_from_list(index) + if restored is None: + raise TypeError( + "slice_tensor does not take a list of indices (use select_list_items for that); " + "pass an int stop, a slice, or a tuple of slices/ints" + ) + index = restored + if isinstance(index, slice | tuple): + return value[index] + raise TypeError( + f"slice_tensor index must be an int stop, a slice, or a tuple of slices/ints, got {type(index).__name__}" + ) + + +def _coerce_int_like(arg: Any, label: str) -> int: + """Turn a 0-d / single-element integer tensor or numpy scalar into a Python ``int``.""" + if isinstance(arg, torch.Tensor): + if arg.dim() > 1 or arg.numel() != 1: + raise ValueError(f"{label} must be a 0-d or single-element integer tensor, got shape {tuple(arg.shape)}") + if arg.dtype == torch.bool or torch.is_floating_point(arg) or torch.is_complex(arg): + raise TypeError(f"{label} must be an integer tensor, got dtype {arg.dtype}") + return int(arg.item()) + if isinstance(arg, np.ndarray): + if arg.ndim > 1 or arg.size != 1: + raise ValueError(f"{label} must be a 0-d or single-element integer array, got shape {tuple(arg.shape)}") + if not np.issubdtype(arg.dtype, np.integer): + raise TypeError(f"{label} must be an integer array, got dtype {arg.dtype}") + return int(arg.item()) + if isinstance(arg, np.integer): + return int(arg) + raise TypeError(f"{label} must be an integer tensor/array/scalar, got {type(arg).__name__}") + + +def _coerce_parser_args(parser_args: Any, num_keys: int) -> Sequence[Any]: + """Coerce ``slice_tensor`` length args from tensor/numpy to ``int`` / ``list[int]``. + + Callers often pass ``attention_mask.sum(-1)`` or ``np.array(lengths)``; those must become + Python ints before the get request is sent so storage units never see a Tensor as a slice index. + """ + if isinstance(parser_args, torch.Tensor | np.ndarray): + if parser_args.ndim == 0 and num_keys == 1: + return [_coerce_int_like(parser_args, "parser_args")] + if parser_args.ndim != 1: + raise ValueError( + f"parser_args tensor/array must be 1-d with length {num_keys}, got shape {tuple(parser_args.shape)}" + ) + if len(parser_args) != num_keys: + raise ValueError( + f"parser_args has {len(parser_args)} entries but {num_keys} keys are fetched; give one per key" + ) + return [_coerce_int_like(item, f"parser_args[{i}]") for i, item in enumerate(parser_args)] + + if not isinstance(parser_args, Sequence) or isinstance(parser_args, str | bytes): + raise TypeError( + f"parser_args must be a sequence of per-key args (or a 1-d integer tensor/array), " + f"got {type(parser_args).__name__}" + ) + + coerced: list[Any] = [] + changed = False + for i, arg in enumerate(parser_args): + if isinstance(arg, np.integer) or isinstance(arg, torch.Tensor | np.ndarray): + coerced.append(_coerce_int_like(arg, f"parser_args[{i}]")) + changed = True + else: + coerced.append(arg) + return coerced if changed else parser_args + + +def validate_get_parser( + parser: GetParser | None, parser_args: Sequence[Any] | None, num_keys: int +) -> Sequence[Any] | None: + """Check a ``kv_batch_get`` parser request before anything is sent to the storage units. + + When ``parser is slice_tensor``, returns ``parser_args`` with tensor/numpy length args coerced to + ``int`` / ``list[int]`` (see ``_coerce_parser_args``). Other parsers leave ``parser_args`` + unchanged. Callers must use the return value. + """ + if parser is None: + if parser_args is not None: + raise ValueError("parser_args was given without a parser; pass parser= as well or drop parser_args") + return None + if not callable(parser): + raise TypeError(f"parser must be callable, got {type(parser).__name__}") + if parser_args is not None: + if parser is slice_tensor: + parser_args = _coerce_parser_args(parser_args, num_keys) + if len(parser_args) != num_keys: + raise ValueError( + f"parser_args has {len(parser_args)} entries but {num_keys} keys are fetched; give one per key" + ) + return parser_args + + +def check_parser_select_fields( + parser: GetParser | None, + select_fields: list[str] | str | None, + field_names: Sequence[str], +) -> None: + """Fail early when a parser would run on every field of a multi-field get without ``select_fields``. + + One get applies one parser to every selected field; omitting ``select_fields`` is allowed only + when ``field_names`` has at most one field. Heterogeneous parsing needs separate gets. + """ + if parser is None or select_fields is not None: + return + if len(field_names) > 1: + raise ValueError( + "kv_batch_get with a parser omits select_fields but the keys have multiple fields " + f"({list(field_names)}); one get applies one parser to every selected field. " + "Pass select_fields limited to compatible fields, or issue separate gets for " + "heterogeneous field parsing" + ) + + +def apply_get_parser( + field_data: dict[str, list[Any]], + parser: GetParser, + parser_args: Sequence[Any] | None, + global_indexes: Sequence[int], +) -> dict[str, list[Any]]: + """Replace every already-loaded value by ``parser(value, parser_arg)`` of its key. + + Values arrive unwrapped from NonTensor*. Call this only after the storage unit has decoded + the stored value (including any SSD read). The reply then serializes the parsed subset; + disk I/O for the full blob is unchanged. + Parser errors raise ``ValueError`` (field, global index, arg); kv_batch_get callers get ``RuntimeError``.""" + if parser_args is None: + parser_args = [None] * len(global_indexes) + elif len(parser_args) != len(global_indexes): + raise ValueError(f"got {len(parser_args)} parser_args for {len(global_indexes)} global indexes") + parser_name = getattr(parser, "__qualname__", type(parser).__name__) + parsed: dict[str, list[Any]] = {} + for field, values in field_data.items(): + column = [] + for value, parser_arg, global_index in zip(values, parser_args, global_indexes, strict=True): + try: + column.append(parser(_unwrap_non_tensor(value), parser_arg)) + except Exception as e: + raise ValueError( + f"get parser {parser_name} failed on field {field!r} of global index {global_index} " + f"with parser_arg {reprlib.repr(parser_arg)}: {type(e).__name__}: {e}" + ) from e + parsed[field] = column + return parsed + + +def _unwrap_non_tensor(value: Any) -> Any: + if isinstance(value, NonTensorStack): + return value.tolist() + if isinstance(value, NonTensorData): + return value.data + return value From 61b4f9ea54784424cb110a12f03d7b5320baeb5c Mon Sep 17 00:00:00 2001 From: OutstanderWang Date: Wed, 14 Oct 2026 21:01:45 +0800 Subject: [PATCH 13/14] [refactor] kv_batch_get: replace get parsers with a put-symmetric data_parser select_items applied one dim-0 selection to every selected field, which is too rigid when columns need different handling. kv_batch_get now takes an optional data_parser(data, args) -> dict with the same contract as kv_batch_put's data_parser: each SimpleStorage unit runs it on its share of the batch before replying, and the result must keep the keys and per-field element counts. Optional parser_args are aligned with keys and routed to each unit by batch position. - The built-in get parsers (select_list_items, slice_tensor) are removed. - Gets with a parser are not retried, matching put_data. - Put and get share one output check, _apply_data_parser. - A response that fails to serialize becomes an error reply, so a parser returning an unencodable value no longer stalls the unit's only worker. - The client passes the parser to StorageManager.get_data only when set, so custom managers written as get_data(self, metadata) keep working. - KV backends raise NotImplementedError for data_parser, as for put. Signed-off-by: OutstanderWang --- README.md | 16 +- tests/e2e/test_kv_interface_e2e.py | 172 ++-------- tests/test_async_simple_storage_manager.py | 98 ++---- tests/test_client.py | 16 +- tests/test_get_parser.py | 322 ------------------ tests/test_kv_storage_manager.py | 21 +- tests/test_simple_storage_unit.py | 72 ++-- tests/test_storage_request_retry.py | 17 + transfer_queue/__init__.py | 4 - transfer_queue/client.py | 35 +- transfer_queue/interface.py | 120 +++---- transfer_queue/storage/managers/base.py | 23 +- .../managers/simple_storage_manager.py | 41 ++- transfer_queue/storage/simple_storage.py | 109 +++--- transfer_queue/utils/get_parser.py | 256 -------------- 15 files changed, 289 insertions(+), 1033 deletions(-) delete mode 100644 tests/test_get_parser.py delete mode 100644 transfer_queue/utils/get_parser.py diff --git a/README.md b/README.md index ed692f6c..136ab2ff 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ In the data plane, we utilize a pluggable design, enabling TransferQueue to inte Specifically, we provide a `StorageManager` abstraction class that defines the core APIs as follows: - `async def put_data(self, data: TensorDict, metadata: BatchMeta, data_parser=None) -> None` -- `async def get_data(self, metadata: BatchMeta, select_items=None) -> TensorDict` +- `async def get_data(self, metadata: BatchMeta, data_parser=None, parser_args=None) -> TensorDict` - `async def clear_data(self, metadata: BatchMeta, fields: list[str] | None = None) -> None` This class encapsulates the core interaction logic within the TransferQueue system. You only need to write a simple subclass to integrate your custom storage backend. Refer to the [Customize](#customize) section for details. @@ -115,7 +115,7 @@ To simplify the usage of TransferQueue, we provide a Redis-style high-level API - **(async_)kv_put**: Insert/Update a multi-column sample by key, with an optional metadata tag. - **(async_)kv_batch_put**: Put multiple key-value pairs efficiently in batches. -- **(async_)kv_batch_get**: Retrieve samples (by keys), supporting column selection (by fields) and, on SimpleStorage, per-key item selection inside each value (`select_items`). +- **(async_)kv_batch_get**: Retrieve samples (by keys), supporting column selection (by fields) and, on SimpleStorage, an optional `data_parser` run on the storage units. - **(async_)kv_update / (async_)kv_batch_update**: Merge new values into already-produced fields with `merge_fn(old, new)`. SimpleStorage only. - **(async_)kv_list**: List keys and tags (metadata) in a partition. - **(async_)kv_clear**: Remove key-value pairs from storage, or only selected `fields` while keeping the keys. @@ -126,6 +126,18 @@ caller stops waiting, so non-idempotent merge operations must not be retried blindly. Updates are atomic within one storage-unit request; a batch spanning multiple units is not a distributed transaction. +**Advanced: parsing on get.** Like `kv_batch_put`, `kv_batch_get` takes a +`data_parser`, here run by each SimpleStorage unit before it replies, so only the +parsed values cross the network (e.g. trimming padding or picking a few frames). +It is called as `data_parser(data, args)`: `data` maps each selected field to the +unit's stored values, one per sample, and `args` holds the matching entries of the +optional per-key `parser_args`. Each field may be parsed differently, but the +result must keep the keys and the number and order of values. `data` holds the +stored objects themselves, so never modify them in place; build new values +instead. As with put, a get with a parser is not retried on a storage-unit +timeout; the call raises, and retrying it yourself may run the parser again, so +keep it free of side effects. See the `kv_batch_get` docstring for details. + **Key Features** - **Redis-style Semantics**: Familiar KV interface (Put/Get/List) for a zero learning curve. diff --git a/tests/e2e/test_kv_interface_e2e.py b/tests/e2e/test_kv_interface_e2e.py index 1c882ef5..8c545613 100644 --- a/tests/e2e/test_kv_interface_e2e.py +++ b/tests/e2e/test_kv_interface_e2e.py @@ -27,7 +27,7 @@ import ray import torch from omegaconf import OmegaConf -from tensordict import NonTensorStack, TensorDict +from tensordict import TensorDict import transfer_queue as tq @@ -727,163 +727,41 @@ def test_kv_batch_get_nonexistent_key(self, controller, tq_api): # Or it might raise an error about keys not found assert "not found" in str(e).lower() or "empty" in str(e).lower() - -class TestKVBatchGetParserE2E: - """End-to-end tests for kv_batch_get with a parser applied on the SimpleStorage units.""" - - @pytest.fixture(autouse=True) - def require_simple_storage(self, backend_name): + def test_kv_batch_get_data_parser_parses_each_field_with_per_key_args(self, tq_api, backend_name): if backend_name != "SimpleStorage": - pytest.skip("get parsers only run on SimpleStorage units") - - @staticmethod - def put_image_lists(tq_api, partition_id, keys): - image_lists = [ - [torch.full((3, 2, k + i + 1), float(10 * k + i)) for i in range(k + 2)] for k in range(len(keys)) - ] - fields = TensorDict( - {"images": NonTensorStack(*image_lists), "input_ids": torch.arange(len(keys) * 4).view(len(keys), 4)}, - batch_size=len(keys), - ) - tq_api.kv_batch_put(keys=keys, partition_id=partition_id, fields=fields, tags=[{} for _ in keys]) - return image_lists - - def test_kv_batch_get_parser_selects_list_items_per_key(self, controller, tq_api): - partition_id = "test_partition" - keys = ["parser_0", "parser_1", "parser_2", "parser_3"] - image_lists = self.put_image_lists(tq_api, partition_id, keys) - - retrieved = tq_api.kv_batch_get( - keys=["parser_3", "parser_0", "parser_2"], - partition_id=partition_id, - select_fields="images", - parser=tq.select_list_items, - parser_args=[[4, 0], [1], [3, 3, 1]], - ) - - assert list(retrieved.keys()) == ["images"] - expected = [[image_lists[3][4], image_lists[3][0]], [image_lists[0][1]], [image_lists[2][i] for i in (3, 3, 1)]] - for got_row, expected_row in zip(list(retrieved["images"]), expected, strict=True): - assert len(got_row) == len(expected_row) - for got, want in zip(got_row, expected_row, strict=True): - assert_tensor_equal(got, want) - - full = tq_api.kv_batch_get(keys="parser_3", partition_id=partition_id, select_fields="images") - assert len(full["images"][0]) == len(image_lists[3]) + pytest.skip("kv_batch_get data_parser is implemented only for SimpleStorage") - def test_kv_batch_get_parser_selects_video_frames_mixed_with_images(self, controller, tq_api): partition_id = "test_partition" - keys = ["parser_video_0", "parser_video_1"] - # Each row is image, three same-shaped video frames, image, as a VLM row stores them. - rows = [ - [torch.full((3, 4, 8), float(10 * k))] - + [torch.full((3, 4, 4), float(10 * k + f + 1)) for f in range(3)] - + [torch.full((3, 8, 4), float(10 * k + 9))] - for k in range(len(keys)) - ] - fields = TensorDict({"images": NonTensorStack(*rows)}, batch_size=len(keys)) - tq_api.kv_batch_put(keys=keys, partition_id=partition_id, fields=fields, tags=[{} for _ in keys]) - - for parser_args in ([[1], [2]], [[1, 2, 3], [3, 1, 2]], [[0, 2, 4], [1, 3]]): - retrieved = tq_api.kv_batch_get( - keys=keys, - partition_id=partition_id, - select_fields="images", - parser=tq.select_list_items, - parser_args=parser_args, - ) - got_rows = list(retrieved["images"]) - assert len(got_rows) == len(rows) - for got_row, row, item_indices in zip(got_rows, rows, parser_args, strict=True): - assert isinstance(got_row, list) and len(got_row) == len(item_indices) - for got, item_idx in zip(got_row, item_indices, strict=True): - assert_tensor_equal(got, row[item_idx]) - - def test_kv_batch_get_parser_empty_selection(self, controller, tq_api): - partition_id = "test_partition" - keys = ["parser_empty_0", "parser_empty_1"] - self.put_image_lists(tq_api, partition_id, keys) - - retrieved = tq_api.kv_batch_get( - keys=keys, - partition_id=partition_id, - select_fields="images", - parser=tq.select_list_items, - parser_args=[[], []], - ) - - assert list(retrieved["images"]) == [[], []] - - def test_kv_batch_get_parser_trims_tensor_field_to_variable_lengths(self, controller, tq_api): - partition_id = "test_partition" - keys = ["parser_tensor_0", "parser_tensor_1", "parser_tensor_2"] - self.put_image_lists(tq_api, partition_id, keys) - - retrieved = tq_api.kv_batch_get( - keys=keys, - partition_id=partition_id, - select_fields="input_ids", - parser=tq.slice_tensor, - parser_args=[1, 3, 2], - ) - - assert_nested_tensor_equal( - retrieved["input_ids"], [torch.tensor([0]), torch.tensor([4, 5, 6]), torch.tensor([8, 9])] + keys = ["parse_0", "parse_1", "parse_2"] + input_ids = torch.tensor([[1, 2, 0, 0], [3, 4, 5, 0], [6, 0, 0, 0]]) + scores = torch.tensor([1.0, 2.0, 3.0]) + tq_api.kv_batch_put( + keys=keys, partition_id=partition_id, fields=TensorDict({"input_ids": input_ids, "scores": scores}, 3) ) - def test_kv_batch_get_parser_out_of_range_index_raises(self, controller, tq_api): - partition_id = "test_partition" - keys = ["parser_oob_0", "parser_oob_1"] - self.put_image_lists(tq_api, partition_id, keys) - - with pytest.raises(RuntimeError, match=r"failed on field 'images'.*indices \[7\] are out of range"): - tq_api.kv_batch_get( - keys=keys, - partition_id=partition_id, - select_fields="images", - parser=tq.select_list_items, - parser_args=[[0], [7]], - ) + def parse(data, args): + data["input_ids"] = [ids[:n] for ids, n in zip(data["input_ids"], args, strict=True)] + data["scores"] = [score * 10 for score in data["scores"]] + return data - def test_kv_batch_get_parser_exception_reaches_caller(self, controller, tq_api): - partition_id = "test_partition" - keys = ["parser_raise_0"] - self.put_image_lists(tq_api, partition_id, keys) - - with pytest.raises(RuntimeError, match="ZeroDivisionError"): - tq_api.kv_batch_get( - keys=keys, - partition_id=partition_id, - select_fields="images", - parser=lambda value, arg: 1 / 0, - ) + retrieved = tq_api.kv_batch_get(keys=keys, partition_id=partition_id, data_parser=parse, parser_args=[2, 3, 1]) + assert_nested_tensor_equal(retrieved["input_ids"], [input_ids[0, :2], input_ids[1, :3], input_ids[2, :1]]) + assert_tensor_equal(retrieved["scores"], scores * 10) - def test_kv_batch_get_parser_requires_select_fields_for_multi_field(self, controller, tq_api): - partition_id = "test_partition" - keys = ["parser_multi_0"] - self.put_image_lists(tq_api, partition_id, keys) + retrieved = tq_api.kv_batch_get(keys=keys, partition_id=partition_id) + assert_tensor_equal(retrieved["input_ids"], input_ids) + assert_tensor_equal(retrieved["scores"], scores) - with pytest.raises(ValueError, match="omits select_fields but the keys have multiple fields"): + with pytest.raises(ValueError, match="2 entries for 3 keys"): + tq_api.kv_batch_get(keys=keys, partition_id=partition_id, data_parser=parse, parser_args=[2, 3]) + with pytest.raises(ValueError, match="requires data_parser"): + tq_api.kv_batch_get(keys=keys, partition_id=partition_id, parser_args=[2, 3, 1]) + with pytest.raises(ValueError, match="parser_args needs every key"): tq_api.kv_batch_get( - keys=keys, - partition_id=partition_id, - parser=tq.select_list_items, - parser_args=[[0]], + keys=["parse_0", "absent"], partition_id=partition_id, data_parser=parse, parser_args=[1, 1] ) - def test_kv_batch_get_parser_missing_key_raises(self, controller, tq_api): - partition_id = "test_partition" - keys = ["parser_missing_0"] - self.put_image_lists(tq_api, partition_id, keys) - - with pytest.raises(ValueError, match="only 1 of 2 keys were found"): - tq_api.kv_batch_get( - keys=["parser_missing_0", "parser_missing_absent"], - partition_id=partition_id, - select_fields="images", - parser=tq.select_list_items, - parser_args=[[0], [0]], - ) + tq_api.kv_clear(keys=keys, partition_id=partition_id) class TestKVBatchGetByMetaE2E: diff --git a/tests/test_async_simple_storage_manager.py b/tests/test_async_simple_storage_manager.py index 61864497..609be511 100644 --- a/tests/test_async_simple_storage_manager.py +++ b/tests/test_async_simple_storage_manager.py @@ -25,7 +25,6 @@ from transfer_queue.metadata import BatchMeta from transfer_queue.storage import AsyncSimpleStorageManager from transfer_queue.utils.enum_utils import Role -from transfer_queue.utils.get_parser import select_list_items from transfer_queue.utils.zmq_utils import ZMQMessage, ZMQRequestType, ZMQServerInfo @@ -287,6 +286,36 @@ async def fake_get(global_indexes, fields, target_storage_unit=None, **kwargs): assert set(called_with["storage_1"]) == {1, 3} +@pytest.mark.asyncio +async def test_get_data_sends_each_unit_the_parser_args_of_its_samples(): + manager = AsyncSimpleStorageManager.__new__(AsyncSimpleStorageManager) + manager.storage_manager_id = "test_get_parser" + manager.storage_unit_infos = { + su: ZMQServerInfo(role=Role.STORAGE, id=su, ip="127.0.0.1", ports={"put_get_socket": port}) + for su, port in (("storage_0", 19012), ("storage_1", 19013)) + } + batch_meta = BatchMeta( + global_indexes=[3, 0, 1, 2], + partition_ids=["p0"] * 4, + field_schema={"f": {"dtype": torch.float32, "shape": (2,), "is_nested": False, "is_non_tensor": False}}, + production_status=np.ones(4, dtype=np.int8), + ) + called_with: dict[str, tuple] = {} + + async def fake_get(global_indexes, fields, target_storage_unit=None, data_parser=None, parser_args=None, **_): + called_with[target_storage_unit] = (list(global_indexes), data_parser, parser_args) + return fields, {"f": [torch.zeros(2) for _ in global_indexes]} + + manager._get_from_single_storage_unit = fake_get + + def parser(data, args): + return data + + await manager.get_data(batch_meta, data_parser=parser, parser_args=["a", "b", "c", "d"]) + + assert called_with == {"storage_0": ([0, 2], parser, ["b", "d"]), "storage_1": ([3, 1], parser, ["a", "c"])} + + @pytest.mark.asyncio async def test_clear_data_routes_from_hash(): """clear_data should route using global_idx % num_su (hash routing).""" @@ -577,70 +606,3 @@ def test_all_none_to_nontensorstack(self): assert isinstance(result, NonTensorStack) assert result.tolist() == [None, None] - - -def _two_unit_manager(storage_manager_id: str) -> AsyncSimpleStorageManager: - manager = AsyncSimpleStorageManager.__new__(AsyncSimpleStorageManager) - manager.storage_manager_id = storage_manager_id - manager.storage_unit_infos = { - unit_id: ZMQServerInfo(role=Role.STORAGE, id=unit_id, ip="127.0.0.1", ports={"put_get_socket": port}) - for unit_id, port in (("storage_0", 19050), ("storage_1", 19051)) - } - return manager - - -@pytest.mark.asyncio -async def test_get_data_slices_parser_args_per_storage_unit_by_batch_position(): - manager = _two_unit_manager("test_parser_routing") - # global_index % 2 routes 2 and 0 to storage_0, 3 and 1 to storage_1. - batch_meta = BatchMeta( - global_indexes=[3, 0, 2, 1], - partition_ids=["p0"] * 4, - field_schema={"images": {"dtype": None, "shape": None, "is_nested": False, "is_non_tensor": True}}, - production_status=np.ones(4, dtype=np.int8), - ) - parser_args = [["arg_3"], ["arg_0"], ["arg_2"], ["arg_1"]] - requests: dict[str, dict] = {} - - async def fake_get(global_indexes, fields, target_storage_unit=None, parser=None, parser_args=None): - requests[target_storage_unit] = {"global_indexes": global_indexes, "parser": parser, "args": parser_args} - values = [f"value_{gi}_{args[0]}" for gi, args in zip(global_indexes, parser_args, strict=True)] - return fields, {"images": values} - - manager._get_from_single_storage_unit = fake_get - - data = await manager.get_data(batch_meta, parser=select_list_items, parser_args=parser_args) - - assert requests == { - "storage_0": {"global_indexes": [0, 2], "parser": select_list_items, "args": [["arg_0"], ["arg_2"]]}, - "storage_1": {"global_indexes": [3, 1], "parser": select_list_items, "args": [["arg_3"], ["arg_1"]]}, - } - assert list(data["images"]) == ["value_3_arg_3", "value_0_arg_0", "value_2_arg_2", "value_1_arg_1"] - - -@pytest.mark.asyncio -async def test_get_with_parser_rejects_reply_without_parsed_flag(): - manager = _two_unit_manager("test_parser_old_unit") - old_unit_reply = ZMQMessage.create( - request_type=ZMQRequestType.GET_DATA_RESPONSE, # type: ignore[arg-type] - sender_id="storage_0", - body={"data": {"images": [["whole_row"]]}}, - ) - socket = Mock() - socket.send_multipart = AsyncMock() - socket.recv_multipart = AsyncMock(return_value=old_unit_reply.serialize()) - get_from_unit = AsyncSimpleStorageManager._get_from_single_storage_unit.__wrapped__ - - with pytest.raises(RuntimeError, match="storage unit storage_0 replied without applying the kv_batch_get parser"): - await get_from_unit( - manager, - [0], - ["images"], - target_storage_unit="storage_0", - parser=select_list_items, - parser_args=[[0]], - socket=socket, - ) - - sent_body = ZMQMessage.deserialize(socket.send_multipart.await_args.args[0]).body - assert sent_body["parser"] is select_list_items and sent_body["parser_args"] == [[0]] diff --git a/tests/test_client.py b/tests/test_client.py index e5174c9c..cf5c86e7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -435,7 +435,7 @@ def client_setup(mock_controller, mock_storage): async def mock_put_data(data, metadata, data_parser=None): pass # Just pretend to store the data - async def mock_get_data(metadata, **kwargs): + async def mock_get_data(metadata): # Return the test data when requested return TEST_DATA @@ -528,7 +528,7 @@ def test_single_controller_multiple_storages(): async def mock_put_data(data, metadata, data_parser=None): pass # Just pretend to store the data - async def mock_get_data(metadata, **kwargs): + async def mock_get_data(metadata): # Return some test data when requested return TensorDict({"tokens": torch.randint(0, 100, (5, 128))}, batch_size=5) @@ -1476,15 +1476,15 @@ def test_load_storage_checkpoint_without_manager(self, mock_controller, tmp_path @pytest.mark.asyncio -async def test_async_get_data_omits_parser_kwargs_for_old_managers(): - """Plain gets omit parser kwargs so old get_data(self, metadata) managers still work.""" +async def test_async_get_data_passes_data_parser_only_when_set(): + """Custom managers written as get_data(self, metadata) keep serving plain gets.""" class OldStyleManager: async def get_data(self, metadata): return TensorDict({"x": torch.zeros(metadata.size)}, batch_size=[metadata.size]) client = AsyncTransferQueueClient.__new__(AsyncTransferQueueClient) - client.client_id = "old_mgr_client" + client.client_id = "old_manager_client" client.storage_manager = OldStyleManager() metadata = BatchMeta( global_indexes=[0], @@ -1492,8 +1492,6 @@ async def get_data(self, metadata): field_schema={"x": {"dtype": torch.float32, "shape": (1,), "is_nested": False, "is_non_tensor": False}}, ) - result = await client.async_get_data(metadata) - assert "x" in result - + assert "x" in await client.async_get_data(metadata) with pytest.raises(TypeError): - await client.async_get_data(metadata, parser=lambda value, arg: value) + await client.async_get_data(metadata, data_parser=lambda data, args: data) diff --git a/tests/test_get_parser.py b/tests/test_get_parser.py deleted file mode 100644 index 91b96c86..00000000 --- a/tests/test_get_parser.py +++ /dev/null @@ -1,322 +0,0 @@ -# Copyright 2026 The TransferQueue Team -# -# Licensed 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 re - -import numpy as np -import pytest -import torch -from tensordict import NonTensorData, NonTensorStack - -from transfer_queue.utils.get_parser import ( - apply_get_parser, - check_parser_select_fields, - select_list_items, - slice_tensor, - validate_get_parser, -) - - -def test_select_list_items_keeps_requested_order(): - items = [torch.full((2,), float(i)) for i in range(4)] - - selected = select_list_items(items, [3, 0, 3]) - - assert [item[0].item() for item in selected] == [3.0, 0.0, 3.0] - - -def test_select_list_items_empty_selection_returns_empty_list(): - assert select_list_items([1, 2], []) == [] - - -@pytest.mark.parametrize("indices", [[2], [-1]]) -def test_select_list_items_out_of_range_raises(indices): - with pytest.raises(IndexError, match=re.escape(f"indices {indices} are out of range for a list of 2 items")): - select_list_items([1, 2], indices) - - -def test_select_list_items_non_list_value_raises(): - with pytest.raises(TypeError, match="needs a list or tuple value, got Tensor"): - select_list_items(torch.zeros(3), [0]) - - -def test_slice_tensor_prefix_returns_contiguous_view(): - value = torch.arange(12).view(3, 4) - - sliced = slice_tensor(value, 2) - - assert torch.equal(sliced, value[:2]) - assert sliced.data_ptr() == value.data_ptr() - assert sliced.untyped_storage().data_ptr() == value.untyped_storage().data_ptr() - assert sliced.is_contiguous() - - -def test_slice_tensor_accepts_slice_and_tuple_indexes(): - value = torch.arange(24).view(4, 3, 2) - - assert torch.equal(slice_tensor(value, slice(1, 3)), value[1:3]) - assert torch.equal(slice_tensor(value, (slice(None), slice(0, 2))), value[:, :2]) - assert slice_tensor(value, (1, slice(None))).data_ptr() == value[1].data_ptr() - - -def test_slice_tensor_empty_prefix_is_valid_view(): - value = torch.arange(6).view(3, 2) - - sliced = slice_tensor(value, 0) - - assert sliced.shape == (0, 2) - assert sliced.untyped_storage().data_ptr() == value.untyped_storage().data_ptr() - - -@pytest.mark.parametrize("index", [-1, 4]) -def test_slice_tensor_prefix_out_of_range_raises(index): - with pytest.raises(IndexError, match=re.escape(f"prefix length {index} is out of range for dim 0 of size 3")): - slice_tensor(torch.zeros(3, 2), index) - - -def test_slice_tensor_rejects_non_tensor_and_nested(): - with pytest.raises(TypeError, match="needs a torch.Tensor value, got list"): - slice_tensor([1, 2, 3], 1) - nested = torch.nested.as_nested_tensor([torch.ones(2), torch.ones(3)], layout=torch.jagged) - with pytest.raises(TypeError, match="does not support nested tensors"): - slice_tensor(nested, 1) - - -def test_slice_tensor_rejects_bool_and_bad_index_type(): - value = torch.zeros(3) - with pytest.raises(TypeError, match="not bool"): - slice_tensor(value, True) - with pytest.raises(TypeError, match="got str"): - slice_tensor(value, "0:2") - - -def test_slice_tensor_rejects_index_list_unlike_select_list_items(): - """slice_tensor is not advanced indexing; a list of indices is select_list_items-only.""" - value = torch.arange(6).view(3, 2) - with pytest.raises(TypeError, match="does not take a list of indices"): - slice_tensor(value, [0, 2]) - - -def test_slice_tensor_restores_indexing_tuple_after_zmq_roundtrip(): - """msgpack turns indexing tuples into lists; RPC parser_args must still slice multi-dim.""" - from transfer_queue.utils.zmq_utils import ZMQMessage, ZMQRequestType - - index = (slice(None), slice(0, 2)) - msg = ZMQMessage.create( - request_type=ZMQRequestType.GET_DATA, - sender_id="test", - body={"parser_args": [index, (1, slice(None))]}, - ) - decoded_args = ZMQMessage.deserialize(msg.serialize()).body["parser_args"] - assert all(isinstance(arg, list) for arg in decoded_args) - - value = torch.arange(24).view(4, 3, 2) - parsed = apply_get_parser( - {"x": [value, value]}, - slice_tensor, - decoded_args, - global_indexes=[0, 1], - ) - assert torch.equal(parsed["x"][0], value[:, :2]) - assert torch.equal(parsed["x"][1], value[1]) - - # Pure int lists stay rejected even after the same wire path. - int_list_msg = ZMQMessage.create( - request_type=ZMQRequestType.GET_DATA, - sender_id="test", - body={"parser_args": [[0, 2]]}, - ) - int_list_args = ZMQMessage.deserialize(int_list_msg.serialize()).body["parser_args"] - with pytest.raises(ValueError, match="does not take a list of indices"): - apply_get_parser({"x": [value]}, slice_tensor, int_list_args, global_indexes=[0]) - - -def test_apply_get_parser_slice_tensor_keeps_per_key_views(): - field_data = {"input_ids": [torch.arange(4), torch.arange(10, 14)]} - - parsed = apply_get_parser(field_data, slice_tensor, [2, 3], global_indexes=[0, 1]) - - assert torch.equal(parsed["input_ids"][0], torch.tensor([0, 1])) - assert torch.equal(parsed["input_ids"][1], torch.tensor([10, 11, 12])) - assert parsed["input_ids"][0].data_ptr() == field_data["input_ids"][0].data_ptr() - assert parsed["input_ids"][1].data_ptr() == field_data["input_ids"][1].data_ptr() - - -def test_slice_tensor_prefix_encode_sends_only_sliced_nbytes(): - from transfer_queue.utils.serial_utils import MsgpackDecoder, MsgpackEncoder - - value = torch.arange(12, dtype=torch.int64).view(3, 4) - sliced = slice_tensor(value, 2) - assert sliced.is_contiguous() - buffers = MsgpackEncoder().encode(sliced) - - assert buffers[1].nbytes == sliced.nbytes - assert buffers[1].nbytes < value.nbytes - assert torch.equal(MsgpackDecoder().decode(buffers), sliced) - - -def test_slice_tensor_noncontiguous_slice_encodes_correct_values(): - """Column / stride slices are correct; MsgpackEncoder may .contiguous() them first.""" - from transfer_queue.utils.serial_utils import MsgpackDecoder, MsgpackEncoder - - value = torch.arange(12, dtype=torch.int64).view(3, 4) - for index in ((slice(None), slice(0, 2)), slice(None, None, 2)): - sliced = slice_tensor(value, index) - assert not sliced.is_contiguous() - assert torch.equal(MsgpackDecoder().decode(MsgpackEncoder().encode(sliced)), sliced) - - -def test_check_parser_select_fields_rejects_multi_field_without_select_fields(): - with pytest.raises(ValueError, match="omits select_fields but the keys have multiple fields"): - check_parser_select_fields(select_list_items, None, ["images", "input_ids"]) - - -def test_check_parser_select_fields_allows_select_fields_or_single_field(): - check_parser_select_fields(select_list_items, "images", ["images", "input_ids"]) - check_parser_select_fields(select_list_items, ["images"], ["images", "input_ids"]) - check_parser_select_fields(select_list_items, None, ["images"]) - check_parser_select_fields(None, None, ["images", "input_ids"]) - - -def test_validate_get_parser_rejects_args_without_parser(): - with pytest.raises(ValueError, match="parser_args was given without a parser"): - validate_get_parser(None, [[0]], num_keys=1) - - -def test_validate_get_parser_rejects_non_callable(): - with pytest.raises(TypeError, match="parser must be callable, got str"): - validate_get_parser("select_list_items", None, num_keys=1) - - -def test_validate_get_parser_rejects_misaligned_args(): - with pytest.raises(ValueError, match="parser_args has 1 entries but 2 keys are fetched"): - validate_get_parser(select_list_items, [[0]], num_keys=2) - - -def test_validate_get_parser_keeps_plain_int_list_and_slice_args(): - ints = [2, 3] - assert validate_get_parser(slice_tensor, ints, num_keys=2) is ints - slices = [slice(0, 2), (slice(None), slice(0, 1))] - assert validate_get_parser(slice_tensor, slices, num_keys=2) is slices - nested = [[0, 2], [1]] - assert validate_get_parser(select_list_items, nested, num_keys=2) is nested - - -@pytest.mark.parametrize( - "parser_args", - [ - torch.tensor(2), - torch.tensor([2]), - np.int64(2), - np.array(2), - np.array([2]), - ], -) -def test_validate_get_parser_coerces_scalar_length_for_slice_tensor(parser_args): - coerced = validate_get_parser(slice_tensor, [parser_args], num_keys=1) - - assert coerced == [2] - assert all(type(x) is int for x in coerced) - assert torch.equal(slice_tensor(torch.arange(5), coerced[0]), torch.arange(2)) - - -@pytest.mark.parametrize( - "parser_args", - [ - torch.tensor([2, 3]), - np.array([2, 3]), - [torch.tensor(2), np.int64(3)], - [np.array(2), torch.tensor([3])], - ], -) -def test_validate_get_parser_coerces_1d_lengths_to_list_int(parser_args): - coerced = validate_get_parser(slice_tensor, parser_args, num_keys=2) - - assert coerced == [2, 3] - assert all(type(x) is int for x in coerced) - field_data = {"input_ids": [torch.arange(4), torch.arange(10, 14)]} - parsed = apply_get_parser(field_data, slice_tensor, coerced, global_indexes=[0, 1]) - assert torch.equal(parsed["input_ids"][0], torch.tensor([0, 1])) - assert torch.equal(parsed["input_ids"][1], torch.tensor([10, 11, 12])) - - -def test_validate_get_parser_rejects_bad_tensor_shapes_early(): - with pytest.raises(ValueError, match="parser_args tensor/array must be 1-d with length 2"): - validate_get_parser(slice_tensor, torch.tensor([[2, 3]]), num_keys=2) - with pytest.raises(ValueError, match=r"parser_args\[0\] must be a 0-d or single-element integer tensor"): - validate_get_parser(slice_tensor, [torch.zeros(2, 2, dtype=torch.int64)], num_keys=1) - with pytest.raises(ValueError, match=r"parser_args\[0\] must be a 0-d or single-element integer tensor"): - validate_get_parser(slice_tensor, [torch.tensor([[2]])], num_keys=1) - with pytest.raises(TypeError, match=r"parser_args\[0\] must be an integer tensor"): - validate_get_parser(slice_tensor, [torch.tensor(2.0)], num_keys=1) - - -def test_validate_get_parser_coerces_0d_tensor_as_whole_args_for_one_key(): - assert validate_get_parser(slice_tensor, torch.tensor(2), num_keys=1) == [2] - assert validate_get_parser(slice_tensor, np.array(2), num_keys=1) == [2] - - -def test_validate_get_parser_does_not_coerce_select_list_items_args(): - """Coerce is slice_tensor-only; index tensors must stay Sequence-like, not bare int.""" - single = [torch.tensor([2])] - assert validate_get_parser(select_list_items, single, num_keys=1) is single - assert select_list_items(["a", "b", "c"], single[0]) == ["c"] - - for indices in (torch.tensor([0, 2]), np.array([0, 2])): - args = [indices] - assert validate_get_parser(select_list_items, args, num_keys=1) is args - assert select_list_items(["a", "b", "c"], indices) == ["a", "c"] - - -def test_validate_get_parser_custom_callable_skips_length_coerce(): - def custom(value, parser_arg): - return value - - args = [torch.tensor([2])] - assert validate_get_parser(custom, args, num_keys=1) is args - - -def test_apply_get_parser_uses_each_key_arg_on_every_field(): - field_data = {"images": [["a0", "a1", "a2"], ["b0"]], "frames": [["fa0", "fa1", "fa2"], ["fb0", "fb1"]]} - - parsed = apply_get_parser(field_data, select_list_items, [[2, 0], [0]], global_indexes=[5, 9]) - - assert parsed == {"images": [["a2", "a0"], ["b0"]], "frames": [["fa2", "fa0"], ["fb0"]]} - - -def test_apply_get_parser_unwraps_non_tensor_values(): - field_data = {"images": [NonTensorStack("x", "y"), NonTensorData(["z0", "z1"])]} - - parsed = apply_get_parser(field_data, select_list_items, [[1], [0, 1]], global_indexes=[0, 1]) - - assert parsed == {"images": [["y"], ["z0", "z1"]]} - - -def test_apply_get_parser_without_args_passes_none(): - seen_args = [] - - def record_arg(value, parser_arg): - seen_args.append(parser_arg) - return value - - apply_get_parser({"x": [1, 2]}, record_arg, None, global_indexes=[0, 1]) - - assert seen_args == [None, None] - - -def test_apply_get_parser_error_names_field_index_and_arg(): - with pytest.raises(ValueError, match=r"failed on field 'images' of global index 9 with parser_arg \[3\]") as e: - apply_get_parser({"images": [["a0"], ["b0"]]}, select_list_items, [[0], [3]], global_indexes=[5, 9]) - - assert isinstance(e.value.__cause__, IndexError) diff --git a/tests/test_kv_storage_manager.py b/tests/test_kv_storage_manager.py index 5b52f4d2..fb2c39bf 100644 --- a/tests/test_kv_storage_manager.py +++ b/tests/test_kv_storage_manager.py @@ -519,23 +519,12 @@ def test_put_data_custom_backend_meta_length_mismatch_raises_error(test_data_for @patch.object(KVStorageManager, "_connect_to_controller", lambda self: None) -def test_get_data_with_parser_raises_not_implemented(test_data_for_put_data): - mock_storage_client = MagicMock() - with patch(f"{STORAGE_CLIENT_FACTORY_PATH}.create", return_value=mock_storage_client): - manager = KVStorageManager(controller_info=MagicMock(), config={"client_name": "MockClient"}) - - with pytest.raises(NotImplementedError, match="does not support kv_batch_get parsers"): - asyncio.run(manager.get_data(test_data_for_put_data["metadata"], parser=lambda value, arg: value)) - mock_storage_client.get.assert_not_called() - - -@patch.object(KVStorageManager, "_connect_to_controller", lambda self: None) -def test_get_data_parser_args_without_parser_raises(test_data_for_put_data): - """KV backends must not silently ignore parser_args when parser is None.""" +def test_get_data_rejects_data_parser(test_data_for_put_data): mock_storage_client = MagicMock() + config = {"controller_info": MagicMock(), "client_name": "MockClient"} with patch(f"{STORAGE_CLIENT_FACTORY_PATH}.create", return_value=mock_storage_client): - manager = KVStorageManager(controller_info=MagicMock(), config={"client_name": "MockClient"}) + manager = KVStorageManager(controller_info=MagicMock(), config=config) - with pytest.raises(ValueError, match="parser_args was given without a parser"): - asyncio.run(manager.get_data(test_data_for_put_data["metadata"], parser_args=[[0]])) + with pytest.raises(NotImplementedError, match="data_parser is not supported"): + asyncio.run(manager.get_data(test_data_for_put_data["metadata"], data_parser=lambda data, args: data)) mock_storage_client.get.assert_not_called() diff --git a/tests/test_simple_storage_unit.py b/tests/test_simple_storage_unit.py index fe6e8a6e..b180397b 100644 --- a/tests/test_simple_storage_unit.py +++ b/tests/test_simple_storage_unit.py @@ -81,12 +81,10 @@ def send_put(self, client_id, global_indexes, field_data, data_parser=None): self.socket.send_multipart(msg.serialize()) return ZMQMessage.deserialize(self.socket.recv_multipart(copy=False)) - def send_get(self, client_id, global_indexes, fields, parser=None, parser_args=None): + def send_get(self, client_id, global_indexes, fields, data_parser=None, parser_args=None): body = {"global_indexes": global_indexes, "fields": fields} - if parser is not None: - body["parser"] = parser - if parser_args is not None: - body["parser_args"] = parser_args + if data_parser is not None: + body.update(data_parser=data_parser, parser_args=parser_args) msg = ZMQMessage.create( request_type=ZMQRequestType.GET_DATA, sender_id=f"mock_client_{client_id}", @@ -965,6 +963,49 @@ def __call__(self, field_data): client.close() +def test_storage_unit_get_data_parser_parses_each_field_without_changing_stored_values(storage_setup): + _, put_get_address, storage_ip = storage_setup + client = MockStorageClient(put_get_address, storage_ip) + tokens = torch.arange(8).reshape(2, 4) + images = [["i0", "i1", "i2"], ["j0", "j1"]] + response = client.send_put(0, [0, 1], {"tokens": tokens, "images": images}) + assert response.request_type == ZMQRequestType.PUT_DATA_RESPONSE, response.body + + def pick(data, args): + data["tokens"] = [value[:n] for value, (n, _) in zip(data["tokens"], args, strict=True)] + data["images"] = [[value[i] for i in idx] for value, (_, idx) in zip(data["images"], args, strict=True)] + return data + + response = client.send_get(0, [1, 0], ["tokens", "images"], data_parser=pick, parser_args=[(1, [1]), (3, [2, 0])]) + assert response.request_type == ZMQRequestType.GET_DATA_RESPONSE, response.body + torch.testing.assert_close(response.body["data"]["tokens"], [tokens[1, :1], tokens[0, :3]]) + assert response.body["data"]["images"] == [["j1"], ["i2", "i0"]] + + response = client.send_get(0, [0, 1], ["tokens", "images"]) + torch.testing.assert_close(response.body["data"]["tokens"], [tokens[0], tokens[1]]) + assert response.body["data"]["images"] == images + + def drop_one(data, _args): + data["tokens"] = data["tokens"][:-1] + return data + + response = client.send_get(0, [0, 1], ["tokens"], data_parser=drop_one) + assert response.request_type == ZMQRequestType.GET_ERROR + assert "data_parser changed the number of elements" in response.body["message"] + + def unencodable(data, _args): + data["tokens"] = [(x for x in ()) for _ in data["tokens"]] + return data + + response = client.send_get(0, [0, 1], ["tokens"], data_parser=unencodable) + assert response.request_type == ZMQRequestType.GET_ERROR + assert "failed to serialize response" in response.body["message"] + response = client.send_get(0, [0], ["tokens"]) + torch.testing.assert_close(response.body["data"]["tokens"], [tokens[0]]) + + client.close() + + def test_storage_unit_data_parser_validation(storage_setup): """Test that invalid data_parser inputs produce clear error messages.""" _, put_get_address, storage_ip = storage_setup @@ -1175,24 +1216,3 @@ def test_undecodable_request_answered_and_worker_survives(storage_setup): response = client.send_put(0, [0], {"val": [torch.tensor([1.0])]}) assert response.request_type == ZMQRequestType.PUT_DATA_RESPONSE client.close() - - -def test_unserializable_get_response_answered_and_worker_survives(storage_setup): - """A get parser that returns a non-encodable value must not kill the sole worker.""" - _, put_get_address, storage_ip = storage_setup - client = MockStorageClient(put_get_address, storage_ip) - - response = client.send_put(0, [0], {"val": [torch.tensor([1.0])]}) - assert response.request_type == ZMQRequestType.PUT_DATA_RESPONSE - - def generator_parser(value, parser_arg): - return (x for x in [1, 2, 3]) - - response = client.send_get(0, [0], ["val"], parser=generator_parser) - assert response.request_type == ZMQRequestType.GET_ERROR - assert "failed to serialize response" in response.body["message"] - - response = client.send_get(0, [0], ["val"]) - assert response.request_type == ZMQRequestType.GET_DATA_RESPONSE - torch.testing.assert_close(response.body["data"]["val"][0], torch.tensor([1.0])) - client.close() diff --git a/tests/test_storage_request_retry.py b/tests/test_storage_request_retry.py index 70be8985..efab4d60 100644 --- a/tests/test_storage_request_retry.py +++ b/tests/test_storage_request_retry.py @@ -107,6 +107,23 @@ async def test_put_retries_only_without_a_parser(data_parser, expected_attempts) manager.notify_data_update.assert_not_awaited() +@pytest.mark.asyncio +@pytest.mark.parametrize("data_parser, expected_attempts", [(None, 3), (lambda data, args: data, 1)]) +async def test_get_retries_only_without_a_parser(data_parser, expected_attempts): + manager = _manager() + manager._get_from_single_storage_unit = AsyncMock(side_effect=StorageUnitTimeout("no answer")) + _, metadata = _single_sample_batch() + + with ( + patch.object(ssm, "TQ_SIMPLE_STORAGE_MAX_ATTEMPTS", 3), + patch.object(manager, "_diagnose_storage_unit", return_value="diagnosis"), + pytest.raises(StorageUnitTimeout), + ): + await manager.get_data(metadata, data_parser=data_parser) + + assert manager._get_from_single_storage_unit.await_count == expected_attempts + + @pytest.mark.asyncio async def test_update_reports_controller_rejection_after_storage_commit(): manager = _manager() diff --git a/transfer_queue/__init__.py b/transfer_queue/__init__.py index 3554c9df..6c514b2b 100644 --- a/transfer_queue/__init__.py +++ b/transfer_queue/__init__.py @@ -59,7 +59,6 @@ from .sampler.seqlen_balanced_sampler import SeqlenBalancedSampler from .sampler.sequential_sampler import SequentialSampler from .storage import StorageKeyNotFoundError -from .utils.get_parser import GetParser, select_list_items, slice_tensor __all__ = ( [ @@ -84,9 +83,6 @@ "async_kv_clear", "async_kv_update", "KVBatchMeta", - "GetParser", - "select_list_items", - "slice_tensor", ] + [ # KV key locks: process-local and cluster-wide diff --git a/transfer_queue/client.py b/transfer_queue/client.py index c5c4913c..119e3f2e 100644 --- a/transfer_queue/client.py +++ b/transfer_queue/client.py @@ -17,7 +17,6 @@ import os import threading import weakref -from collections.abc import Sequence from typing import Any, Callable import torch @@ -28,7 +27,6 @@ from transfer_queue.metadata import BatchMeta from transfer_queue.storage import StorageManagerFactory from transfer_queue.utils.common import limit_pytorch_auto_parallel_threads -from transfer_queue.utils.get_parser import GetParser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.zmq_utils import ( TQ_SOCKET_POOL_SIZE, @@ -527,16 +525,16 @@ async def async_update( async def async_get_data( self, metadata: BatchMeta, - parser: GetParser | None = None, - parser_args: Sequence[Any] | None = None, + data_parser: Callable[[Any, Any], Any] | None = None, + parser_args: list | None = None, ) -> TensorDict: """Asynchronously fetch data from storage units and organize into TensorDict. Args: metadata: Batch metadata containing data location information and global indexes - parser: Optional get parser the storage units apply to every requested value before replying. - See ``transfer_queue.kv_batch_get``; only supported by SimpleStorage. - parser_args: Optional per-sample parser arguments, aligned with ``metadata.global_indexes``. + data_parser: Optional ``data_parser(data, args)`` the storage units apply before replying. + See ``transfer_queue.kv_batch_get``. Only supported by SimpleStorage. + parser_args: Optional per-sample arguments aligned with ``metadata.global_indexes``. Returns: TensorDict containing: @@ -570,12 +568,13 @@ async def async_get_data( with limit_pytorch_auto_parallel_threads( target_num_threads=TQ_NUM_THREADS, info=f"[{self.client_id}] async_get_data" ): - # Only pass parser kwargs when a parser is used so old custom managers - # with get_data(self, metadata) keep working for plain gets. - if parser is not None: - results = await self.storage_manager.get_data(metadata, parser=parser, parser_args=parser_args) - else: + # Passed only when set, so custom managers with get_data(self, metadata) keep working. + if data_parser is None: results = await self.storage_manager.get_data(metadata) + else: + results = await self.storage_manager.get_data( + metadata, data_parser=data_parser, parser_args=parser_args + ) logger.debug(f"[{self.client_id}]: get_data with {metadata.size} samples successfully.") @@ -1663,16 +1662,16 @@ def update( def get_data( self, metadata: BatchMeta, - parser: GetParser | None = None, - parser_args: Sequence[Any] | None = None, + data_parser: Callable[[Any, Any], Any] | None = None, + parser_args: list | None = None, ) -> TensorDict: """Synchronously fetch data from storage units and organize into TensorDict. Args: metadata: Batch metadata containing data location information and global indexes - parser: Optional get parser the storage units apply to every requested value before replying. - See ``transfer_queue.kv_batch_get``; only supported by SimpleStorage. - parser_args: Optional per-sample parser arguments, aligned with ``metadata.global_indexes``. + data_parser: Optional ``data_parser(data, args)`` the storage units apply before replying. + See ``transfer_queue.kv_batch_get``. Only supported by SimpleStorage. + parser_args: Optional per-sample arguments aligned with ``metadata.global_indexes``. Returns: TensorDict containing: @@ -1690,7 +1689,7 @@ def get_data( >>> print(batch) >>> # TensorDict with fields "prompts", "attention_mask", and sample order matching metadata global_indexes """ - return self._get_data(metadata=metadata, parser=parser, parser_args=parser_args) + return self._get_data(metadata=metadata, data_parser=data_parser, parser_args=parser_args) def clear_partition(self, partition_id: str): """Synchronously clear the whole partition from all storage units and the controller. diff --git a/transfer_queue/interface.py b/transfer_queue/interface.py index a131a87e..492a0517 100644 --- a/transfer_queue/interface.py +++ b/transfer_queue/interface.py @@ -33,7 +33,6 @@ import shutil import subprocess import time -from collections.abc import Sequence from importlib import resources from pathlib import Path from typing import Any, Callable @@ -54,7 +53,6 @@ from transfer_queue.sampler import BaseSampler from transfer_queue.storage.bootstrap import StorageBootstrapProvider from transfer_queue.storage.managers.simple_storage_manager import AsyncSimpleStorageManager -from transfer_queue.utils.get_parser import GetParser, check_parser_select_fields, validate_get_parser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.yuanrong_utils import cleanup_yuanrong_resources from transfer_queue.utils.zmq_utils import process_zmq_server_info @@ -529,8 +527,8 @@ def kv_batch_get( keys: list[str] | str, partition_id: str, select_fields: list[str] | str | None = None, - parser: GetParser | None = None, - parser_args: Sequence[Any] | None = None, + data_parser: Callable[[dict[str, list], list | None], dict[str, list]] | None = None, + parser_args: list | None = None, ) -> TensorDict: """Get data from TransferQueue using user-specified keys. @@ -539,21 +537,28 @@ def kv_batch_get( Args: keys: Single key or list of keys to retrieve partition_id: Partition containing the keys - select_fields: Optional field(s) to retrieve. If None, retrieves all fields. With a ``parser``, pass - ``select_fields`` limited to compatible fields (required when keys have more than one field). - parser: Optional ``parser(value, parser_arg)`` each SimpleStorage unit applies to every selected (key, field) - after loading/decoding the stored value and before replying, e.g. ``transfer_queue.select_list_items``. - Only the parsed subset is re-serialized on the wire (bandwidth); with Hybrid/SSD offload the full - stored blob may still be read from disk first — parsers do not cut disk I/O by themselves. One get - uses one parser and one per-key ``parser_arg`` for every selected field; heterogeneous field parsing - needs separate gets. Pair the parser with ``select_fields`` for compatible fields (omit only when - the keys have a single field). It runs synchronously on the storage unit worker, so keep it light - and side-effect free. Sent by cloudpickle, so a module-level parser must be importable on the - storage units. KV backends raise NotImplementedError. - parser_args: Optional per-key ``parser_arg`` values aligned with ``keys``; None passes None for every key. - For ``slice_tensor`` only, integer ``torch.Tensor`` / ``numpy`` lengths (e.g. ``attention_mask.sum(-1)``) - are coerced to ``int`` / ``list[int]`` before send so storage units never see a Tensor as a slice index. - Other parsers (``select_list_items``, custom callables) leave ``parser_args`` unchanged. + select_fields: Optional field(s) to retrieve. If None, retrieves all fields + data_parser: Advanced. Optional ``data_parser(data, args) -> dict`` that each storage + unit runs on its share of the batch before replying, so only the parsed values cross + the network (the get-side counterpart of ``kv_batch_put``'s ``data_parser``). + ``data`` maps each selected field to a list with one stored value per sample on that + unit, and ``args`` holds the matching ``parser_args`` entries (None without + ``parser_args``). Each field may be parsed differently, but the returned dict must + keep the keys and the number and order of values per field. Constraints: + + - Do not modify stored values in place (e.g. ``tensor.add_()``, ``value.append()``): + ``data`` holds the stored objects, so the change would persist and also reach + later readers. Build new values instead; replacing entries of ``data`` is safe. + - Keep it free of side effects. Like ``kv_batch_put`` with a parser, a storage-unit + timeout is not retried and the call raises; retrying it yourself may run the parser + again for the same samples. + - It is sent with cloudpickle, so a module-level function must be importable on the + storage units. SSD-offloaded values are read in full before parsing. + + Only supported by SimpleStorage; KV backends raise ``NotImplementedError``. + parser_args: Optional per-key arguments for ``data_parser``, aligned with ``keys``. + Requires ``data_parser``, and every key must exist. They are serialized like + request data, so tuples arrive as lists. Returns: TensorDict with the requested data @@ -561,9 +566,9 @@ def kv_batch_get( Raises: ValueError: If keys or partition are not found ValueError: If empty fields exist in any key (sample) - ValueError: If a parser is given and some keys are missing - ValueError: If a parser is given without ``select_fields`` and the keys have multiple fields - RuntimeError: If the parser fails on a storage unit; the message names the field, global index and parser_arg + ValueError: If ``parser_args`` is given without ``data_parser``, does not match the + number of keys, or some keys are missing + RuntimeError: If ``data_parser`` fails or breaks its contract on a storage unit Example: >>> import transfer_queue as tq @@ -576,48 +581,31 @@ def kv_batch_get( ... partition_id="train", ... select_fields="input_ids" ... ) - >>> # List fields: select_list_items takes per-key Sequence[int] indices (reorder/repeat OK). - >>> # select_fields keeps the parser off tensor fields such as input_ids (use another get for those). + >>> # Trim each key's input_ids and pick some of its images on the storage units + >>> def pick(data, args): + ... data["input_ids"] = [ids[:n] for ids, (n, _) in zip(data["input_ids"], args)] + ... data["images"] = [[imgs[i] for i in idx] for imgs, (_, idx) in zip(data["images"], args)] + ... return data >>> data = tq.kv_batch_get( ... keys=["sample_1", "sample_2"], ... partition_id="train", - ... select_fields="images", - ... parser=tq.select_list_items, - ... parser_args=[[0, 2], [1]], - ... ) - >>> # Tensor fields: slice_tensor takes per-key int (dim-0 prefix), slice, or indexing tuple — - >>> # not a list of indices like select_list_items. Contiguous dim-0 prefixes avoid encode materialization. - >>> data = tq.kv_batch_get( - ... keys=["sample_1", "sample_2"], - ... partition_id="train", - ... select_fields="input_ids", - ... parser=tq.slice_tensor, - ... parser_args=[3, 5], + ... select_fields=["input_ids", "images"], + ... data_parser=pick, + ... parser_args=[(5, [0, 2]), (3, [1])], ... ) """ - keys = [keys] if isinstance(keys, str) else keys - parser_args = validate_get_parser(parser, parser_args, len(keys)) tq_client = _maybe_create_tq_client() return tq_client._run_coroutine( async_kv_batch_get( keys=keys, partition_id=partition_id, select_fields=select_fields, - parser=parser, + data_parser=data_parser, parser_args=parser_args, ) ) -def _check_all_keys_found(num_found: int, keys: list[str], partition_id: str, parser: GetParser | None) -> None: - # NOTE(neowywang): a missing key is dropped from the meta, which would shift parser_args onto other keys. - if parser is not None and num_found != len(keys): - raise ValueError( - f"only {num_found} of {len(keys)} keys were found in partition {partition_id!r}; a kv_batch_get " - "with a parser needs every key, since parser_args are aligned with keys" - ) - - def kv_list(partition_id: str | None = None) -> dict[str, dict[str, Any]]: """List all keys and their metadata in one or all partitions. @@ -1001,8 +989,8 @@ async def async_kv_batch_get( keys: list[str] | str, partition_id: str, select_fields: list[str] | str | None = None, - parser: GetParser | None = None, - parser_args: Sequence[Any] | None = None, + data_parser: Callable[[dict[str, list], list | None], dict[str, list]] | None = None, + parser_args: list | None = None, ) -> TensorDict: """Asynchronously get data from TransferQueue using user-specified keys. @@ -1011,14 +999,10 @@ async def async_kv_batch_get( Args: keys: Single key or list of keys to retrieve partition_id: Partition containing the keys - select_fields: Optional field(s) to retrieve. If None, retrieves all fields. With a ``parser``, pass - ``select_fields`` limited to compatible fields (required when keys have more than one field). - parser: Optional get parser each SimpleStorage unit applies after load/decode and before replying; - wire size shrinks to the parsed subset, but Hybrid/SSD may still read the full stored blob first. - One get uses one parser for every selected field. Pair with ``select_fields``; omit only when keys - have a single field. Heterogeneous field parsing needs separate gets. See ``kv_batch_get``. - parser_args: Optional per-key ``parser_arg`` values aligned with ``keys``; None passes None for every key. - Length coerce applies to ``slice_tensor`` only, same as ``kv_batch_get``. + select_fields: Optional field(s) to retrieve. If None, retrieves all fields + data_parser: Advanced. Optional parser the storage units run before replying; see + ``kv_batch_get`` for its contract and constraints. + parser_args: Optional per-key arguments for ``data_parser``, aligned with ``keys``. Returns: TensorDict with the requested data @@ -1026,9 +1010,9 @@ async def async_kv_batch_get( Raises: ValueError: If keys or partition are not found ValueError: If empty fields exist in any key (sample) - ValueError: If a parser is given and some keys are missing - ValueError: If a parser is given without ``select_fields`` and the keys have multiple fields - RuntimeError: If the parser fails on a storage unit; the message names the field, global index and parser_arg + ValueError: If ``parser_args`` is given without ``data_parser``, does not match the + number of keys, or some keys are missing + RuntimeError: If ``data_parser`` fails or breaks its contract on a storage unit Example: >>> import transfer_queue as tq @@ -1043,16 +1027,20 @@ async def async_kv_batch_get( ... ) """ keys = [keys] if isinstance(keys, str) else keys - parser_args = validate_get_parser(parser, parser_args, len(keys)) + if parser_args is not None: + if data_parser is None: + raise ValueError("parser_args requires data_parser") + if len(parser_args) != len(keys): + raise ValueError(f"parser_args has {len(parser_args)} entries for {len(keys)} keys") tq_client = _maybe_create_tq_client() batch_meta = await tq_client.async_kv_retrieve_meta(keys=keys, partition_id=partition_id, create=False) if batch_meta.size == 0: raise ValueError("keys or partition were not found!") - _check_all_keys_found(batch_meta.size, keys, partition_id, parser) - # Field set is known only after meta retrieve; fail before get_data when parser would hit all fields. - check_parser_select_fields(parser, select_fields, batch_meta.field_names) + # Missing keys are dropped from the meta, which would shift parser_args onto other keys. + if parser_args is not None and batch_meta.size != len(keys): + raise ValueError(f"only {batch_meta.size} of {len(keys)} keys were found; parser_args needs every key") if select_fields is not None: if isinstance(select_fields, str): @@ -1064,7 +1052,7 @@ async def async_kv_batch_get( if not batch_meta.is_ready: raise ValueError("Some fields are not ready in all the requested keys!") - data = await tq_client.async_get_data(batch_meta, parser=parser, parser_args=parser_args) + data = await tq_client.async_get_data(batch_meta, data_parser=data_parser, parser_args=parser_args) return data diff --git a/transfer_queue/storage/managers/base.py b/transfer_queue/storage/managers/base.py index b265a4a4..b111ac2c 100644 --- a/transfer_queue/storage/managers/base.py +++ b/transfer_queue/storage/managers/base.py @@ -21,7 +21,6 @@ import time import weakref from abc import ABC, abstractmethod -from collections.abc import Sequence from concurrent.futures import ThreadPoolExecutor from typing import Any, Callable from uuid import uuid4 @@ -36,7 +35,6 @@ from transfer_queue.metadata import BatchMeta, extract_field_schema from transfer_queue.storage.clients.base import StorageClientFactory -from transfer_queue.utils.get_parser import GetParser, validate_get_parser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.zmq_utils import ( STORAGE_MANAGER_IDENTITY_PREFIX, @@ -339,16 +337,18 @@ async def put_data( async def get_data( self, metadata: BatchMeta, - parser: GetParser | None = None, - parser_args: Sequence[Any] | None = None, + data_parser: Callable[[Any, Any], Any] | None = None, + parser_args: list | None = None, ) -> TensorDict: """ Get data from the storage backend. Args: metadata: BatchMeta of the data to be retrieved from the storage. - parser: Optional get parser applied to every requested value where it is stored, before sending. - parser_args: Optional per-sample parser arguments, aligned with ``metadata.global_indexes``. + data_parser: Optional ``data_parser(data, args)`` applied where the data is stored, + before it is sent. See ``transfer_queue.kv_batch_get``. Only supported by + SimpleStorage backend. + parser_args: Optional per-sample arguments aligned with ``metadata.global_indexes``. Returns: TensorDict containing the data retrieved from the storage. @@ -835,8 +835,8 @@ async def put_data( async def get_data( self, metadata: BatchMeta, - parser: GetParser | None = None, - parser_args: Sequence[Any] | None = None, + data_parser: Callable[[Any, Any], Any] | None = None, + parser_args: list | None = None, ) -> TensorDict: """ Retrieve tensor data from the backend storage. @@ -844,12 +844,9 @@ async def get_data( Fetches tensors using the provided metadata, reconstructs them with the correct shapes and dtypes, and merge them as a TensorDict according to metadata. """ - validate_get_parser(parser, parser_args, metadata.size) - if parser is not None: + if data_parser is not None: raise NotImplementedError( - f"{type(self).__name__} does not support kv_batch_get parsers: KV backends return whole (key, field) " - "values and cannot run a parser where the data is stored. Use the SimpleStorage backend, or fetch " - "without parser= and select the needed part on the caller side" + "data_parser is not supported for KV-based backends (MooncakeStore, Yuanrong, RayStore)." ) if not metadata.field_names: logger.warning("Attempted to get data, but metadata contains no fields.") diff --git a/transfer_queue/storage/managers/simple_storage_manager.py b/transfer_queue/storage/managers/simple_storage_manager.py index badaed0b..57f22c2d 100644 --- a/transfer_queue/storage/managers/simple_storage_manager.py +++ b/transfer_queue/storage/managers/simple_storage_manager.py @@ -18,7 +18,7 @@ import time import warnings from collections import defaultdict -from collections.abc import Mapping, Sequence +from collections.abc import Mapping from functools import partial from operator import itemgetter from pathlib import Path @@ -36,7 +36,6 @@ StorageKeyNotFoundError, ) from transfer_queue.utils.common import log_heavy_operation -from transfer_queue.utils.get_parser import GetParser, validate_get_parser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.zmq_utils import ( TQ_SOCKET_POOL_SIZE, @@ -783,8 +782,8 @@ def _pack_field_values(values: list) -> torch.Tensor | NonTensorStack: async def get_data( self, metadata: BatchMeta, - parser: GetParser | None = None, - parser_args: Sequence[Any] | None = None, + data_parser: Callable[[Any, Any], Any] | None = None, + parser_args: list | None = None, ) -> TensorDict: """ Retrieve data from remote StorageUnit based on metadata. @@ -793,9 +792,10 @@ async def get_data( Args: metadata: BatchMeta that contains metadata for data retrieval. - parser: Optional ``parser(value, parser_arg)`` each SU applies after load/decode; only the parsed - subset is serialized on the wire (Hybrid/SSD may still read the full blob first). - parser_args: Optional per-sample parser arguments, aligned with ``metadata.global_indexes``. + data_parser: Optional ``data_parser(data, args)`` each SU applies to its part of the + batch before replying. See ``transfer_queue.kv_batch_get``. + parser_args: Optional per-sample arguments aligned with ``metadata.global_indexes``; + each SU receives the slice for its samples as ``args``. Returns: TensorDict containing the retrieved data. @@ -805,9 +805,10 @@ async def get_data( if metadata.size == 0: return TensorDict({}, batch_size=0) - parser_args = validate_get_parser(parser, parser_args, metadata.size) routing = self._group_by_hash(metadata.global_indexes) + # Parser-backed gets are not replayed, matching put: the public API does not constrain parser side effects. + max_attempts = 1 if data_parser is not None else None tasks = [ self._request_with_retry( @@ -819,9 +820,10 @@ async def get_data( group.global_indexes, metadata.field_names, target_storage_unit=su_id, - parser=parser, - parser_args=None if parser_args is None else [parser_args[pos] for pos in group.batch_positions], + data_parser=data_parser, + parser_args=None if parser_args is None else [parser_args[p] for p in group.batch_positions], ), + max_attempts=max_attempts, ) for su_id, group in routing.items() ] @@ -859,19 +861,21 @@ async def _get_from_single_storage_unit( global_indexes: list[int], fields: list[str], target_storage_unit: str, - parser: GetParser | None = None, - parser_args: list[Any] | None = None, + data_parser: Callable[[Any, Any], Any] | None = None, + parser_args: list | None = None, socket: zmq.Socket = None, ): """Get data from a single SU by global index keys.""" - body = {"global_indexes": global_indexes, "fields": fields} - if parser is not None: - body.update(parser=parser, parser_args=parser_args) request_msg = ZMQMessage.create( request_type=ZMQRequestType.GET_DATA, # type: ignore[arg-type] sender_id=self.storage_manager_id, receiver_id=target_storage_unit, - body=body, + body={ + "global_indexes": global_indexes, + "fields": fields, + "data_parser": data_parser, + "parser_args": parser_args, + }, ) try: await socket.send_multipart(request_msg.serialize()) @@ -879,11 +883,6 @@ async def _get_from_single_storage_unit( response_msg = ZMQMessage.deserialize(messages) if response_msg.request_type == ZMQRequestType.GET_DATA_RESPONSE: - if parser is not None and not response_msg.body.get("parsed", False): - raise RuntimeError( - f"storage unit {target_storage_unit} replied without applying the kv_batch_get parser; it " - "runs a TransferQueue older than get parsers, so restart the storage units on this version" - ) storage_unit_data = response_msg.body["data"] return fields, storage_unit_data else: diff --git a/transfer_queue/storage/simple_storage.py b/transfer_queue/storage/simple_storage.py index 53feed21..0abc510d 100644 --- a/transfer_queue/storage/simple_storage.py +++ b/transfer_queue/storage/simple_storage.py @@ -51,7 +51,6 @@ from transfer_queue.utils import compact_pickle from transfer_queue.utils.common import limit_pytorch_auto_parallel_threads, log_heavy_operation from transfer_queue.utils.enum_utils import Role -from transfer_queue.utils.get_parser import apply_get_parser from transfer_queue.utils.logging_utils import get_logger from transfer_queue.utils.perf_utils import IntervalPerfMonitor from transfer_queue.utils.zmq_utils import ( @@ -833,6 +832,38 @@ def select_dump_schema(schema: dict, source_indexes: list[int], target_indexes: return selected +def _element_count(value: Any) -> int | None: + if hasattr(value, "shape") and isinstance(value.shape, tuple | list) and len(value.shape) > 0: + return value.shape[0] + try: + return len(value) + except Exception: + return None + + +def _apply_data_parser(field_data: dict[str, Any], data_parser: Callable[..., Any], *args: Any) -> dict[str, Any]: + """Run a user ``data_parser(field_data, *args)`` and check it kept every key and element count.""" + if not callable(data_parser): + raise TypeError(f"data_parser must be callable, got {type(data_parser).__name__}") + + original_lengths = {k: _element_count(v) for k, v in field_data.items()} + parsed = data_parser(field_data, *args) + + if not isinstance(parsed, dict): + raise TypeError(f"data_parser must return a dict, got {type(parsed).__name__}") + if set(parsed) != set(original_lengths): + raise ValueError( + f"data_parser must not change dict keys. Original keys: {sorted(original_lengths)}, got: {sorted(parsed)}" + ) + for k, v in parsed.items(): + orig_len, new_len = original_lengths[k], _element_count(v) + if orig_len is not None and new_len is not None and orig_len != new_len: + raise ValueError( + f"data_parser changed the number of elements for key '{k}': expected {orig_len}, got {new_len}" + ) + return parsed + + @ray.remote(num_cpus=1) class SimpleStorageUnit: """A storage unit that provides distributed data storage functionality. @@ -1150,8 +1181,7 @@ def _process_one_worker_request(self, worker_socket: zmq.Socket, monitor: Any) - }, ) - # Serialize is outside the handler try: a success body can still fail encode - # (e.g. get parser returns a generator). Contain that here so the sole worker lives. + # A get data_parser can return a value that fails to encode; reply with an error so the sole worker lives. try: response_frames = response_msg.serialize() except Exception as e: @@ -1159,18 +1189,12 @@ def _process_one_worker_request(self, worker_socket: zmq.Socket, monitor: Any) - f"[{self.storage_unit_id}]: failed to serialize response for {operation} " f"from sender={request_msg.sender_id}: {type(e).__name__}: {e}" ) - error_type = ( - ZMQRequestType.GET_ERROR - if operation == ZMQRequestType.GET_DATA # type: ignore[arg-type] - else ZMQRequestType.PUT_GET_ERROR - ) response_frames = ZMQMessage.create( - request_type=error_type, # type: ignore[arg-type] + request_type=ZMQRequestType.GET_ERROR # type: ignore[arg-type] + if operation == ZMQRequestType.GET_DATA # type: ignore[arg-type] + else ZMQRequestType.PUT_GET_ERROR, sender_id=self.storage_unit_id, - body={ - "message": f"{self.storage_unit_id}: failed to serialize response " - f"for {operation}: {type(e).__name__}: {e}" - }, + body={"message": f"{self.storage_unit_id}: failed to serialize response: {type(e).__name__}: {e}"}, ).serialize() else: if operation == ZMQRequestType.GET_DATA: # type: ignore[arg-type] @@ -1204,47 +1228,7 @@ def _handle_put(self, data_parts: ZMQMessage) -> ZMQMessage: target_num_threads=TQ_NUM_THREADS, info=f"[{self.storage_unit_id}] _handle_put" ): if data_parser is not None: - if not callable(data_parser): - raise TypeError(f"data_parser must be callable, got {type(data_parser).__name__}") - - original_keys = set(field_data.keys()) - original_lengths = {} - for k, v in field_data.items(): - if hasattr(v, "shape") and isinstance(v.shape, tuple | list) and len(v.shape) > 0: - original_lengths[k] = v.shape[0] - else: - try: - original_lengths[k] = len(v) - except Exception: - original_lengths[k] = None - - field_data = data_parser(field_data) - - if not isinstance(field_data, dict): - raise TypeError(f"data_parser must return a dict, got {type(field_data).__name__}") - - new_keys = set(field_data.keys()) - if new_keys != original_keys: - raise ValueError( - f"data_parser must not change dict keys. " - f"Original keys: {sorted(original_keys)}, got: {sorted(new_keys)}" - ) - - for k, v in field_data.items(): - if hasattr(v, "shape") and isinstance(v.shape, tuple | list) and len(v.shape) > 0: - new_len = v.shape[0] - else: - try: - new_len = len(v) - except Exception: - new_len = None - - orig_len = original_lengths[k] - if orig_len is not None and new_len is not None and orig_len != new_len: - raise ValueError( - f"data_parser changed the number of elements for key '{k}': " - f"expected {orig_len}, got {new_len}" - ) + field_data = _apply_data_parser(field_data, data_parser) self.storage_data.put_data(field_data, global_indexes) # After put operation finish, send a message to the client @@ -1312,26 +1296,21 @@ def _handle_get(self, data_parts: ZMQMessage) -> ZMQMessage: try: fields = data_parts.body["fields"] global_indexes = data_parts.body["global_indexes"] - parser = data_parts.body.get("parser") + data_parser = data_parts.body.get("data_parser") with limit_pytorch_auto_parallel_threads( target_num_threads=TQ_NUM_THREADS, info=f"[{self.storage_unit_id}] _handle_get" ): - # get_data may read/decode the full SSD-backed blob; the parser only shrinks the reply wire size. result_data = self.storage_data.get_data(fields, global_indexes) - if parser is not None: - result_data = apply_get_parser( - result_data, parser, data_parts.body.get("parser_args"), global_indexes - ) + if data_parser is not None: + result_data = _apply_data_parser(result_data, data_parser, data_parts.body.get("parser_args")) - response_body: dict[str, Any] = {"data": result_data} - if parser is not None: - # NOTE(neowywang): lets the manager tell a parsed reply from an older unit that ignored the parser. - response_body["parsed"] = True response_msg = ZMQMessage.create( request_type=ZMQRequestType.GET_DATA_RESPONSE, # type: ignore[arg-type] sender_id=self.storage_unit_id, - body=response_body, + body={ + "data": result_data, + }, ) except Exception as e: key_not_found = isinstance(e, StorageKeyNotFoundError) diff --git a/transfer_queue/utils/get_parser.py b/transfer_queue/utils/get_parser.py deleted file mode 100644 index 9f00d16c..00000000 --- a/transfer_queue/utils/get_parser.py +++ /dev/null @@ -1,256 +0,0 @@ -# Copyright 2026 The TransferQueue Team -# -# Licensed 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. - -"""Get parsers: callables a SimpleStorage unit applies to stored values before replying to ``kv_batch_get``. - -A parser runs after the storage unit has loaded and decoded the stored value. It trims that value -to the part the caller needs; only the parsed subset is re-serialized and sent on the wire -(bandwidth). With Hybrid/SSD offload (and similar), the full stored blob may still be read from -disk and decoded before parsing — ``parser_args`` alone do not reduce disk I/O. - -One get uses one ``parser`` and one per-key ``parser_arg`` for every selected field. Heterogeneous -field parsing (different parsers or args per field) needs separate gets. Pair a parser with -``select_fields`` limited to compatible fields; omitting ``select_fields`` is only valid when the -keys have a single field.""" - -import reprlib -from collections.abc import Callable, Sequence -from typing import Any - -import numpy as np -import torch -from tensordict import NonTensorData, NonTensorStack - -GetParser = Callable[[Any, Any], Any] -"""``parser(value, parser_arg) -> parsed value``, called once per selected (key, field).""" - - -def select_list_items(value: Sequence[Any], indices: Sequence[int]) -> list[Any]: - """Return the items of a list-valued field at ``indices`` (a ``Sequence[int]``), in that order. - - Reorder and repeats are allowed (e.g. ``[3, 0, 3]``). An index outside ``[0, len(value))`` - raises ``IndexError`` instead of wrapping around. This is not the same API as ``slice_tensor``, - which takes a dim-0 prefix length, ``slice``, or indexing ``tuple`` — not a list of indices. - """ - if not isinstance(value, list | tuple): - raise TypeError(f"select_list_items needs a list or tuple value, got {type(value).__name__}") - out_of_range = [index for index in indices if not 0 <= index < len(value)] - if out_of_range: - raise IndexError(f"indices {out_of_range} are out of range for a list of {len(value)} items") - return [value[index] for index in indices] - - -def _indexing_tuple_from_list(index: list) -> tuple[Any, ...] | None: - """Restore a multi-dim indexing tuple that msgpack decoded as a list, or return None. - - Untyped msgpack turns ``(slice(None), slice(0, 2))`` into ``[slice(None), slice(0, 2)]``. - Accept that shape so RPC ``parser_args`` still work; keep rejecting a pure ``list[int]`` - (that is ``select_list_items`` advanced-index style, not ``value[i, j, ...]``). - """ - if not index: - return None - has_slice = False - for item in index: - if isinstance(item, bool): - return None - if isinstance(item, slice): - has_slice = True - elif isinstance(item, int) or item is None or item is Ellipsis: - continue - else: - return None - return tuple(index) if has_slice else None - - -def slice_tensor(value: torch.Tensor, index: int | slice | tuple[Any, ...]) -> torch.Tensor: - """Return ``value`` sliced by ``index`` so only that logical content is serialized and sent on get. - - ``index`` is an ``int`` dim-0 prefix length (``value[:index]``), a ``slice``, or an indexing - ``tuple`` applied as ``value[index]``. It is not a list of indices like ``select_list_items``; - pass ``[0, 2]`` and you get ``TypeError``, not advanced indexing. After RPC, msgpack may - deliver an indexing tuple as a list that still contains at least one ``slice``; that form is - restored to a tuple. Wire size follows the sliced logical content in all cases. - - Encode without ``.contiguous()`` materialization only when the sliced result ``is_contiguous()`` - (typical: dim-0 prefix ``[:n]`` of a C-contiguous tensor). Non-contiguous slices such as - ``[:, :k]`` or ``[::2]`` still round-trip correctly, but ``MsgpackEncoder`` may call - ``.contiguous()`` before extracting the buffer. - """ - if not isinstance(value, torch.Tensor): - raise TypeError(f"slice_tensor needs a torch.Tensor value, got {type(value).__name__}") - if value.is_nested: - raise TypeError("slice_tensor does not support nested tensors; pass a regular tensor field") - if isinstance(index, bool): - raise TypeError("slice_tensor index must be an int stop, a slice, or a tuple of slices/ints, not bool") - if isinstance(index, int): - if value.dim() == 0: - raise IndexError("slice_tensor cannot take a prefix length of a 0-dim tensor") - if not 0 <= index <= value.size(0): - raise IndexError(f"prefix length {index} is out of range for dim 0 of size {value.size(0)}") - return value[:index] - if isinstance(index, list): - # msgpack loses tuple vs list; only lists with a slice are treated as indexing tuples. - restored = _indexing_tuple_from_list(index) - if restored is None: - raise TypeError( - "slice_tensor does not take a list of indices (use select_list_items for that); " - "pass an int stop, a slice, or a tuple of slices/ints" - ) - index = restored - if isinstance(index, slice | tuple): - return value[index] - raise TypeError( - f"slice_tensor index must be an int stop, a slice, or a tuple of slices/ints, got {type(index).__name__}" - ) - - -def _coerce_int_like(arg: Any, label: str) -> int: - """Turn a 0-d / single-element integer tensor or numpy scalar into a Python ``int``.""" - if isinstance(arg, torch.Tensor): - if arg.dim() > 1 or arg.numel() != 1: - raise ValueError(f"{label} must be a 0-d or single-element integer tensor, got shape {tuple(arg.shape)}") - if arg.dtype == torch.bool or torch.is_floating_point(arg) or torch.is_complex(arg): - raise TypeError(f"{label} must be an integer tensor, got dtype {arg.dtype}") - return int(arg.item()) - if isinstance(arg, np.ndarray): - if arg.ndim > 1 or arg.size != 1: - raise ValueError(f"{label} must be a 0-d or single-element integer array, got shape {tuple(arg.shape)}") - if not np.issubdtype(arg.dtype, np.integer): - raise TypeError(f"{label} must be an integer array, got dtype {arg.dtype}") - return int(arg.item()) - if isinstance(arg, np.integer): - return int(arg) - raise TypeError(f"{label} must be an integer tensor/array/scalar, got {type(arg).__name__}") - - -def _coerce_parser_args(parser_args: Any, num_keys: int) -> Sequence[Any]: - """Coerce ``slice_tensor`` length args from tensor/numpy to ``int`` / ``list[int]``. - - Callers often pass ``attention_mask.sum(-1)`` or ``np.array(lengths)``; those must become - Python ints before the get request is sent so storage units never see a Tensor as a slice index. - """ - if isinstance(parser_args, torch.Tensor | np.ndarray): - if parser_args.ndim == 0 and num_keys == 1: - return [_coerce_int_like(parser_args, "parser_args")] - if parser_args.ndim != 1: - raise ValueError( - f"parser_args tensor/array must be 1-d with length {num_keys}, got shape {tuple(parser_args.shape)}" - ) - if len(parser_args) != num_keys: - raise ValueError( - f"parser_args has {len(parser_args)} entries but {num_keys} keys are fetched; give one per key" - ) - return [_coerce_int_like(item, f"parser_args[{i}]") for i, item in enumerate(parser_args)] - - if not isinstance(parser_args, Sequence) or isinstance(parser_args, str | bytes): - raise TypeError( - f"parser_args must be a sequence of per-key args (or a 1-d integer tensor/array), " - f"got {type(parser_args).__name__}" - ) - - coerced: list[Any] = [] - changed = False - for i, arg in enumerate(parser_args): - if isinstance(arg, np.integer) or isinstance(arg, torch.Tensor | np.ndarray): - coerced.append(_coerce_int_like(arg, f"parser_args[{i}]")) - changed = True - else: - coerced.append(arg) - return coerced if changed else parser_args - - -def validate_get_parser( - parser: GetParser | None, parser_args: Sequence[Any] | None, num_keys: int -) -> Sequence[Any] | None: - """Check a ``kv_batch_get`` parser request before anything is sent to the storage units. - - When ``parser is slice_tensor``, returns ``parser_args`` with tensor/numpy length args coerced to - ``int`` / ``list[int]`` (see ``_coerce_parser_args``). Other parsers leave ``parser_args`` - unchanged. Callers must use the return value. - """ - if parser is None: - if parser_args is not None: - raise ValueError("parser_args was given without a parser; pass parser= as well or drop parser_args") - return None - if not callable(parser): - raise TypeError(f"parser must be callable, got {type(parser).__name__}") - if parser_args is not None: - if parser is slice_tensor: - parser_args = _coerce_parser_args(parser_args, num_keys) - if len(parser_args) != num_keys: - raise ValueError( - f"parser_args has {len(parser_args)} entries but {num_keys} keys are fetched; give one per key" - ) - return parser_args - - -def check_parser_select_fields( - parser: GetParser | None, - select_fields: list[str] | str | None, - field_names: Sequence[str], -) -> None: - """Fail early when a parser would run on every field of a multi-field get without ``select_fields``. - - One get applies one parser to every selected field; omitting ``select_fields`` is allowed only - when ``field_names`` has at most one field. Heterogeneous parsing needs separate gets. - """ - if parser is None or select_fields is not None: - return - if len(field_names) > 1: - raise ValueError( - "kv_batch_get with a parser omits select_fields but the keys have multiple fields " - f"({list(field_names)}); one get applies one parser to every selected field. " - "Pass select_fields limited to compatible fields, or issue separate gets for " - "heterogeneous field parsing" - ) - - -def apply_get_parser( - field_data: dict[str, list[Any]], - parser: GetParser, - parser_args: Sequence[Any] | None, - global_indexes: Sequence[int], -) -> dict[str, list[Any]]: - """Replace every already-loaded value by ``parser(value, parser_arg)`` of its key. - - Values arrive unwrapped from NonTensor*. Call this only after the storage unit has decoded - the stored value (including any SSD read). The reply then serializes the parsed subset; - disk I/O for the full blob is unchanged. - Parser errors raise ``ValueError`` (field, global index, arg); kv_batch_get callers get ``RuntimeError``.""" - if parser_args is None: - parser_args = [None] * len(global_indexes) - elif len(parser_args) != len(global_indexes): - raise ValueError(f"got {len(parser_args)} parser_args for {len(global_indexes)} global indexes") - parser_name = getattr(parser, "__qualname__", type(parser).__name__) - parsed: dict[str, list[Any]] = {} - for field, values in field_data.items(): - column = [] - for value, parser_arg, global_index in zip(values, parser_args, global_indexes, strict=True): - try: - column.append(parser(_unwrap_non_tensor(value), parser_arg)) - except Exception as e: - raise ValueError( - f"get parser {parser_name} failed on field {field!r} of global index {global_index} " - f"with parser_arg {reprlib.repr(parser_arg)}: {type(e).__name__}: {e}" - ) from e - parsed[field] = column - return parsed - - -def _unwrap_non_tensor(value: Any) -> Any: - if isinstance(value, NonTensorStack): - return value.tolist() - if isinstance(value, NonTensorData): - return value.data - return value From e7bc18da5250c90f8c6f5ca89dbdd56282a487dc Mon Sep 17 00:00:00 2001 From: OutstanderWang Date: Wed, 14 Oct 2026 21:13:57 +0800 Subject: [PATCH 14/14] fix: validate parser_args in the client and cover the parsed reply size parser_args without data_parser was dropped silently by TransferQueueClient.get_data, which only forwards the parser arguments when a parser is set, and by KV backends. The client now rejects it, and checks that parser_args matches the number of samples, so direct client callers get the same errors as kv_batch_get. kv_batch_get keeps only the every-key-found check, which needs the keys. Adds a test that a parser returning a view sends only the view's bytes. Signed-off-by: OutstanderWang --- tests/e2e/test_kv_interface_e2e.py | 2 +- tests/test_client.py | 2 ++ tests/test_simple_storage_unit.py | 12 ++++++++++++ transfer_queue/client.py | 5 +++++ transfer_queue/interface.py | 11 +++-------- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/tests/e2e/test_kv_interface_e2e.py b/tests/e2e/test_kv_interface_e2e.py index 8c545613..228f22f6 100644 --- a/tests/e2e/test_kv_interface_e2e.py +++ b/tests/e2e/test_kv_interface_e2e.py @@ -752,7 +752,7 @@ def parse(data, args): assert_tensor_equal(retrieved["input_ids"], input_ids) assert_tensor_equal(retrieved["scores"], scores) - with pytest.raises(ValueError, match="2 entries for 3 keys"): + with pytest.raises(ValueError, match="2 entries for 3 samples"): tq_api.kv_batch_get(keys=keys, partition_id=partition_id, data_parser=parse, parser_args=[2, 3]) with pytest.raises(ValueError, match="requires data_parser"): tq_api.kv_batch_get(keys=keys, partition_id=partition_id, parser_args=[2, 3, 1]) diff --git a/tests/test_client.py b/tests/test_client.py index cf5c86e7..0fd98761 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1495,3 +1495,5 @@ async def get_data(self, metadata): assert "x" in await client.async_get_data(metadata) with pytest.raises(TypeError): await client.async_get_data(metadata, data_parser=lambda data, args: data) + with pytest.raises(ValueError, match="parser_args requires data_parser"): + await client.async_get_data(metadata, parser_args=[1]) diff --git a/tests/test_simple_storage_unit.py b/tests/test_simple_storage_unit.py index b180397b..001c924e 100644 --- a/tests/test_simple_storage_unit.py +++ b/tests/test_simple_storage_unit.py @@ -46,8 +46,10 @@ SimpleStorageUnit, SSDFileStore, StorageKeyNotFoundError, + _apply_data_parser, _SSDValueRef, ) +from transfer_queue.utils.serial_utils import MsgpackDecoder, MsgpackEncoder from transfer_queue.utils.zmq_utils import ( STORAGE_MANAGER_IDENTITY_PREFIX, ZMQMessage, @@ -1006,6 +1008,16 @@ def unencodable(data, _args): client.close() +def test_get_data_parser_view_sends_only_the_parsed_bytes(): + value = torch.arange(12, dtype=torch.int64).view(3, 4) + parsed = _apply_data_parser({"x": [value]}, lambda data, _args: {"x": [v[:2] for v in data["x"]]}, None) + + buffers = MsgpackEncoder().encode(parsed) + assert parsed["x"][0].data_ptr() == value.data_ptr() + assert buffers[1].nbytes == value[:2].nbytes < value.nbytes + assert torch.equal(MsgpackDecoder().decode(buffers)["x"][0], value[:2]) + + def test_storage_unit_data_parser_validation(storage_setup): """Test that invalid data_parser inputs produce clear error messages.""" _, put_get_address, storage_ip = storage_setup diff --git a/transfer_queue/client.py b/transfer_queue/client.py index 119e3f2e..7ee7714a 100644 --- a/transfer_queue/client.py +++ b/transfer_queue/client.py @@ -564,6 +564,11 @@ async def async_get_data( if not metadata or metadata.size == 0 or len(metadata.field_names) == 0: logger.warning(f"[{self.client_id}]: Empty BatchMeta provided to get_data. Returning empty TensorDict.") return TensorDict({}, batch_size=0) + if parser_args is not None: + if data_parser is None: + raise ValueError("parser_args requires data_parser") + if len(parser_args) != metadata.size: + raise ValueError(f"parser_args has {len(parser_args)} entries for {metadata.size} samples") with limit_pytorch_auto_parallel_threads( target_num_threads=TQ_NUM_THREADS, info=f"[{self.client_id}] async_get_data" diff --git a/transfer_queue/interface.py b/transfer_queue/interface.py index 492a0517..3d6be893 100644 --- a/transfer_queue/interface.py +++ b/transfer_queue/interface.py @@ -1026,12 +1026,6 @@ async def async_kv_batch_get( ... select_fields="input_ids" ... ) """ - keys = [keys] if isinstance(keys, str) else keys - if parser_args is not None: - if data_parser is None: - raise ValueError("parser_args requires data_parser") - if len(parser_args) != len(keys): - raise ValueError(f"parser_args has {len(parser_args)} entries for {len(keys)} keys") tq_client = _maybe_create_tq_client() batch_meta = await tq_client.async_kv_retrieve_meta(keys=keys, partition_id=partition_id, create=False) @@ -1039,8 +1033,9 @@ async def async_kv_batch_get( if batch_meta.size == 0: raise ValueError("keys or partition were not found!") # Missing keys are dropped from the meta, which would shift parser_args onto other keys. - if parser_args is not None and batch_meta.size != len(keys): - raise ValueError(f"only {batch_meta.size} of {len(keys)} keys were found; parser_args needs every key") + num_keys = 1 if isinstance(keys, str) else len(keys) + if parser_args is not None and batch_meta.size != num_keys: + raise ValueError(f"only {batch_meta.size} of {num_keys} keys were found; parser_args needs every key") if select_fields is not None: if isinstance(select_fields, str):