This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 222864e164 [python] Avoid duplicate LeRobot video batch copies (#10405)
222864e164 is described below

commit 222864e16489b787e7ae80eea44f1d1ba1070d02
Author: XiaoHongbo <[email protected]>
AuthorDate: Wed Oct 7 18:18:28 2026 +0800

    [python] Avoid duplicate LeRobot video batch copies (#10405)
---
 docs/docs/pypaimon/lerobot.md                      |  10 +-
 .../pypaimon/multimodal/lerobot/dataset.py         | 120 ++++++++++++++++++++-
 .../pypaimon/tests/multimodal_lerobot_test.py      |  77 +++++++++++++
 3 files changed, 200 insertions(+), 7 deletions(-)

diff --git a/docs/docs/pypaimon/lerobot.md b/docs/docs/pypaimon/lerobot.md
index e97ee770e7..518a5d0674 100644
--- a/docs/docs/pypaimon/lerobot.md
+++ b/docs/docs/pypaimon/lerobot.md
@@ -211,16 +211,22 @@ For map-style training, pass an image- or video-backed 
table group created by
 `PaimonLeRobotWriter` is not sufficient.
 
 ```python
-from torch.utils.data import DataLoader
 from pypaimon.multimodal import PaimonLeRobotDataset
 
 dataset = PaimonLeRobotDataset(
     conn.get_table("robot_data"),
     tag_name=tag,
 )
-loader = DataLoader(dataset, batch_size=32, shuffle=True, num_workers=4)
+loader = dataset.to_dataloader(
+    batch_size=32,
+    shuffle=True,
+    num_workers=4,
+)
 ```
 
+`to_dataloader` writes decoded video windows directly into shared worker
+batches, avoiding a second full-resolution copy during collation.
+
 Without `tag_name`, the latest snapshots are used. Frame lookups use the BTree
 on `index`; payloads remain lazy. Indexed videos prefetch metadata and target 
GOPs, then fetch
 uncached PyAV reads on demand. Unindexed videos use the TorchCodec/PyAV scan 
path. Set
diff --git a/paimon-python/pypaimon/multimodal/lerobot/dataset.py 
b/paimon-python/pypaimon/multimodal/lerobot/dataset.py
index f48ce7b446..665ceb1478 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/dataset.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/dataset.py
@@ -364,6 +364,13 @@ class PaimonDatasetReader(ABC):
 
     def get_items(self, indices):
         """Return fully assembled frames for one batch."""
+        return self._get_items(indices, return_batch=False, share_memory=False)
+
+    def _get_batch(self, indices, share_memory):
+        return self._get_items(
+            indices, return_batch=True, share_memory=share_memory)
+
+    def _get_items(self, indices, return_batch, share_memory):
         dataset_indices = [
             _normalize_index(index, len(self)) for index in indices
         ]
@@ -432,7 +439,8 @@ class PaimonDatasetReader(ABC):
         # Transforms may shrink frames; assemble those windows one sample at a 
time.
         video_windows = {} if self.image_transforms is not None else 
_decode_video_windows(
             plans, rows, getattr(self, "_video_collators", ()),
-            self._features, self.return_uint8)
+            self._features, self.return_uint8,
+            return_batch=return_batch, share_memory=share_memory)
         for group in row_groups:
             for row in group.values():
                 for key in video_windows:
@@ -477,6 +485,8 @@ class PaimonDatasetReader(ABC):
                 for key in self._visual_keys:
                     item[key] = self.image_transforms(item[key])
             result.append(item)
+        if return_batch:
+            return _collate_lerobot_batch(result, video_windows)
         return result
 
     def __getitem__(self, index):
@@ -663,6 +673,18 @@ class PaimonLeRobotDataset:
     def __getitems__(self, indices):
         return self.reader.get_items(indices)
 
+    def to_dataloader(self, **kwargs):
+        """Create a DataLoader that assembles video windows once per batch."""
+        collate_fn = kwargs.pop("collate_fn", None)
+        if collate_fn is not None:
+            raise ValueError(
+                "PaimonLeRobotDataset.to_dataloader manages collate_fn.")
+        from torch.utils.data import DataLoader
+        num_workers = kwargs.get("num_workers", 0)
+        batched = _LeRobotDataLoaderAdapter(
+            self, share_memory=bool(num_workers))
+        return DataLoader(batched, collate_fn=_identity, **kwargs)
+
     @property
     def return_uint8(self):
         return self.reader.return_uint8
@@ -703,6 +725,46 @@ class PaimonLeRobotDataset:
             self.reader.__class__.__name__, self.__class__.__name__, 1)
 
 
+class _LeRobotDataLoaderAdapter:
+
+    def __init__(self, dataset, share_memory):
+        self.dataset = dataset
+        self.share_memory = share_memory
+
+    def __len__(self):
+        return len(self.dataset)
+
+    def __getitem__(self, index):
+        return self.dataset[index]
+
+    def __getitems__(self, indices):
+        return self.dataset.reader._get_batch(indices, self.share_memory)
+
+    @property
+    def return_uint8(self):
+        return self.dataset.return_uint8
+
+    @return_uint8.setter
+    def return_uint8(self, value):
+        self.dataset.return_uint8 = value
+
+    @property
+    def image_transforms(self):
+        return self.dataset.image_transforms
+
+    @image_transforms.setter
+    def image_transforms(self, value):
+        self.dataset.image_transforms = value
+
+    def __getattr__(self, name):
+        if name.startswith("__") and name.endswith("__"):
+            raise AttributeError(name)
+        dataset = self.__dict__.get("dataset")
+        if dataset is None:
+            raise AttributeError(name)
+        return getattr(dataset, name)
+
+
 class _PaimonLeRobotMetadata:
 
     def __init__(
@@ -1347,7 +1409,9 @@ def _attach_task_labels(rows, task_names, subtask_names):
             row["subtask"] = subtask_names[subtask_index]
 
 
-def _decode_video_windows(plans, rows, collators, features, return_uint8):
+def _decode_video_windows(
+        plans, rows, collators, features, return_uint8, *,
+        return_batch=False, share_memory=False):
     import torch
 
     tasks = [c for c in collators if c.video_column in plans[0]["windows"]]
@@ -1381,7 +1445,7 @@ def _decode_video_windows(plans, rows, collators, 
features, return_uint8):
             requests.setdefault((payload, keyframe_index), []).append((
                 offset, [d.frame_index for d in window]))
 
-        output = [None] * len(plans)
+        output = None if return_batch else [None] * len(plans)
         with torch.inference_mode(inference_enabled), \
                 torch.set_grad_enabled(grad_enabled):
             for (payload, keyframe_index), windows in requests.items():
@@ -1399,17 +1463,49 @@ def _decode_video_windows(plans, rows, collators, 
features, return_uint8):
                 if sum(len(window) for _, window in windows) >= 2 * 
len(indices):
                     frames = frames.contiguous()
                 positions = {index: pos for pos, index in enumerate(indices)}
+                if return_batch:
+                    window_sizes = {len(window) for _, window in windows}
+                    if len(window_sizes) != 1:
+                        raise ValueError(
+                            "Video batch windows must have the same size.")
+                    window_size = next(iter(window_sizes))
+                    output_dtype = torch.float32 \
+                        if frames.dtype == torch.uint8 and not return_uint8 \
+                        else frames.dtype
+                    shape = ((len(plans), window_size)
+                             + tuple(frames.shape[1:]))
+                    if output is None:
+                        output = torch.empty(
+                            shape, dtype=output_dtype, device=frames.device)
+                        if share_memory:
+                            output.share_memory_()
+                    elif tuple(output.shape) != shape \
+                            or output.dtype != output_dtype:
+                        raise ValueError(
+                            "Video batch payloads have incompatible frames.")
                 for offset, window in windows:
                     selection = [positions[index] for index in window]
                     start = selection[0]
-                    if selection == list(range(start, start + len(selection))):
+                    contiguous = selection == list(
+                        range(start, start + len(selection)))
+                    if return_batch:
+                        source = frames[start:start + len(selection)] \
+                            if contiguous else frames.index_select(
+                                0, torch.tensor(
+                                    selection, dtype=torch.long,
+                                    device=frames.device))
+                        output[offset].copy_(source)
+                    elif contiguous:
                         output[offset] = frames[start:start + 
len(selection)].clone(
                             memory_format=torch.contiguous_format)
                     else:
                         output[offset] = frames.index_select(0, torch.tensor(
                             selection, dtype=torch.long, device=frames.device))
                     if frames.dtype == torch.uint8 and not return_uint8:
-                        output[offset] = output[offset].float().div_(255)
+                        if return_batch:
+                            output[offset].div_(255)
+                        else:
+                            output[offset] = output[offset].float().div_(255)
         return key, output
 
     if len(tasks) == 1:
@@ -1421,6 +1517,20 @@ def _decode_video_windows(plans, rows, collators, 
features, return_uint8):
     return {key: windows for key, windows in decoded if windows is not None}
 
 
+def _collate_lerobot_batch(rows, video_windows):
+    from torch.utils.data import default_collate
+
+    if not video_windows:
+        return default_collate(rows)
+    remaining = [{
+        key: value for key, value in row.items()
+        if key not in video_windows
+    } for row in rows]
+    batch = default_collate(remaining)
+    batch.update(video_windows)
+    return batch
+
+
 def _stack_visual_windows(plans, rows, visual_keys):
     import torch
 
diff --git a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py 
b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
index 1f5da1f922..f39d7e1add 100644
--- a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
@@ -150,6 +150,17 @@ def _catalog_metadata(connection, name):
     }
 
 
+def _increment_image(value):
+    return value + 1
+
+
+def _configure_uint8_transform_worker(unused_worker_id):
+    from torch.utils.data import get_worker_info
+    dataset = get_worker_info().dataset
+    dataset.return_uint8 = True
+    dataset.image_transforms = _increment_image
+
+
 class LeRobotValidationTest(unittest.TestCase):
 
     def test_episode_metadata_pickle_stays_small_and_usable(self):
@@ -930,6 +941,12 @@ class LeRobotValidationTest(unittest.TestCase):
             sample["action"], torch.tensor([0.0, 1.0, 2.0]))
         self.assertEqual([False, False, False],
                          sample["action_is_pad"].tolist())
+        batch = next(iter(dataset.to_dataloader(
+            batch_size=2, shuffle=False, num_workers=0)))
+        self.assertEqual([0, 1], batch["index"].tolist())
+        self.assertEqual([2, 3], list(batch["action"].shape))
+        with self.assertRaisesRegex(ValueError, "manages collate_fn"):
+            dataset.to_dataloader(batch_size=2, collate_fn=lambda rows: rows)
         dataset.return_uint8 = True
         self.assertTrue(reader.return_uint8)
         with self.assertRaisesRegex(TypeError, "return_uint8"):
@@ -1455,6 +1472,10 @@ class LeRobotValidationTest(unittest.TestCase):
                             item[key], expected.expand(3, 3, 2, 2).float()))
                 result[0][keys[0]].zero_()
                 self.assertEqual(1, result[0][keys[1]][-1, 0, 0, 0].item())
+                batch = reader._get_batch([0, 1], share_memory=False)
+                for key in keys:
+                    self.assertEqual(
+                        [2, 3, 3, 2, 2], list(batch[key].shape))
 
     def test_video_windows_decode_directly_and_fall_back(self):
         try:
@@ -1497,6 +1518,22 @@ class LeRobotValidationTest(unittest.TestCase):
                 self.assertTrue(torch.equal(batch, frames))
                 decoder.get_frames_at.assert_called_once_with(indices=[0, 1])
 
+            decoder.get_frames_at.reset_mock()
+            decoder.get_frames_at.return_value = SimpleNamespace(data=frames)
+            result = _decode_video_windows(
+                plans, rows, [collator], feature, False,
+                return_batch=True, share_memory=True)[key]
+            expected = torch.stack([
+                frames[[1, 0, 0]], frames[[1, 0, 0]]
+            ]).float().div(255)
+            self.assertTrue(result.is_shared())
+            self.assertTrue(torch.equal(result, expected))
+            result[0, 1].zero_()
+            self.assertTrue(torch.equal(result[0, 2], expected[0, 2]))
+            self.assertTrue(torch.equal(result[1], expected[1]))
+            self.assertTrue(torch.equal(frames, batch))
+            decoder.get_frames_at.assert_called_once_with(indices=[0, 1])
+
             for indices in ([0, 1], [1, 0, 0]):
                 decoder.get_frames_at.reset_mock()
                 decoder.get_frames_at.return_value = 
SimpleNamespace(data=frames)
@@ -1527,6 +1564,18 @@ class LeRobotValidationTest(unittest.TestCase):
             self.assertTrue(torch.equal(result[1], batch))
             self.assertTrue(torch.equal(batch, frames))
 
+            fixed = [{"windows": {key: window}}
+                     for window in ([1, 0], [2, 3], [0, 1], [3, 2])]
+            result = _decode_video_windows(
+                fixed, separate, [collator], feature, True,
+                return_batch=True, share_memory=True)[key]
+            expected = torch.stack([
+                batch[[1, 0]], batch[[0, 1]],
+                batch[[0, 1]], batch[[1, 0]],
+            ])
+            self.assertTrue(result.is_shared())
+            self.assertTrue(torch.equal(result, expected))
+
             open_decoder.reset_mock()
             mixed = dict(rows)
             mixed[1] = {key: VideoFrameDescriptor("b.video", 0, 1, 0, -1, 
0).serialize()}
@@ -3096,6 +3145,16 @@ class LeRobotValidationTest(unittest.TestCase):
                         multiprocessing_context="spawn"):
                     worker_indices.extend(batch["index"].tolist())
                 self.assertEqual(list(range(5)), worker_indices)
+
+                worker_indices = []
+                for batch in dataset.to_dataloader(
+                        batch_size=2,
+                        shuffle=False,
+                        num_workers=2,
+                        multiprocessing_context="spawn"):
+                    worker_indices.extend(batch["index"].tolist())
+                    self.assertTrue(batch["camera"].is_shared())
+                self.assertEqual(list(range(5)), worker_indices)
             finally:
                 dataset.close()
 
@@ -3926,6 +3985,24 @@ class LeRobotImportTest(unittest.TestCase):
             worker_indices.extend(batch["index"].tolist())
         self.assertEqual(list(range(5)), worker_indices)
 
+        worker_batches = list(dataset.to_dataloader(
+            batch_size=2,
+            shuffle=False,
+            num_workers=1,
+            multiprocessing_context="spawn",
+            worker_init_fn=_configure_uint8_transform_worker,
+        ))
+        self.assertEqual("torch.uint8", str(
+            worker_batches[0]["observation.image"].dtype))
+        self.assertEqual(
+            1.0,
+            float(worker_batches[0]["observation.image"][0].float().mean()),
+        )
+        self.assertEqual(
+            101.0,
+            float(worker_batches[-1]["observation.image"][-1].float().mean()),
+        )
+
         uint8_dataset = pmm.PaimonLeRobotDataset(
             table,
             tag_name="training",

Reply via email to