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 2620ab68c1 [Python] Decode LeRobot video payloads in parallel (#10703)
2620ab68c1 is described below

commit 2620ab68c148a869e0aa91d899e41b72fc99f2cd
Author: XiaoHongbo <[email protected]>
AuthorDate: Wed Oct 21 20:03:21 2026 +0800

    [Python] Decode LeRobot video payloads in parallel (#10703)
---
 docs/docs/pypaimon/lerobot.md                      |   3 +
 .../pypaimon/common/options/core_options.py        |  16 +-
 .../pypaimon/multimodal/lerobot/dataset.py         |  96 +++--
 paimon-python/pypaimon/multimodal/video.py         |  72 +++-
 .../pypaimon/tests/multimodal_lerobot_test.py      |   2 +-
 .../tests/video_decode_parallelism_test.py         | 455 +++++++++++++++++++++
 6 files changed, 618 insertions(+), 26 deletions(-)

diff --git a/docs/docs/pypaimon/lerobot.md b/docs/docs/pypaimon/lerobot.md
index 45120edb2f..ae7ca1910a 100644
--- a/docs/docs/pypaimon/lerobot.md
+++ b/docs/docs/pypaimon/lerobot.md
@@ -256,6 +256,9 @@ Reads stay on the selected snapshot; unavailable or 
unsupported native paths use
 Set `table = table.copy({"read.video.max-open-decoders": "8"})` before
 constructing the dataset to limit cached decoders per camera per worker 
(default: 16).
 Each open decoder uses memory and a file handle, so usage grows with cameras 
and workers.
+`read.video.decode-parallelism` limits concurrent video decodes across all 
cameras in each
+Dataset worker (default: 16). Set it to 1 for serial decoding. Parallel 
decoding
+applies to video windows without `image_transforms` and uses more CPU and 
memory.
 
 Subclass `PaimonDatasetReader` for a custom logical frame layout:
 
diff --git a/paimon-python/pypaimon/common/options/core_options.py 
b/paimon-python/pypaimon/common/options/core_options.py
index a9c544303e..77f40584ee 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -1172,6 +1172,15 @@ class CoreOptions:
             "LeRobot DataLoader worker.")
     )
 
+    READ_VIDEO_DECODE_PARALLELISM: ConfigOption[int] = (
+        ConfigOptions.key("read.video.decode-parallelism")
+        .int_type()
+        .default_value(16)
+        .with_description(
+            "Maximum concurrent video payload decodes shared by all cameras in 
a "
+            "PyPaimon LeRobot Dataset worker.")
+    )
+
     PARQUET_COLUMN_INDEX_ENABLED: ConfigOption[bool] = (
         ConfigOptions.key("parquet.filter.columnindex.enabled")
         .boolean_type()
@@ -1937,7 +1946,12 @@ class CoreOptions:
         return self.options.get(CoreOptions.READ_BATCH_SIZE, default or 1024)
 
     def read_video_max_open_decoders(self) -> int:
-        option = CoreOptions.READ_VIDEO_MAX_OPEN_DECODERS
+        return 
self._positive_video_option(CoreOptions.READ_VIDEO_MAX_OPEN_DECODERS)
+
+    def read_video_decode_parallelism(self) -> int:
+        return 
self._positive_video_option(CoreOptions.READ_VIDEO_DECODE_PARALLELISM)
+
+    def _positive_video_option(self, option) -> int:
         raw = self.options.to_map().get(option.key())
         if isinstance(raw, (bool, float)):
             raise ValueError("%s must be a positive integer." % option.key())
diff --git a/paimon-python/pypaimon/multimodal/lerobot/dataset.py 
b/paimon-python/pypaimon/multimodal/lerobot/dataset.py
index 22a0fb65c9..21d4bb8b48 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/dataset.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/dataset.py
@@ -50,7 +50,7 @@ from pypaimon.multimodal.lerobot.schema import (
     _validate_lerobot_schema,
 )
 from pypaimon.multimodal.table import _target_schema, _time_travel_table
-from pypaimon.multimodal.video import VideoFrameCollator
+from pypaimon.multimodal.video import VideoFrameCollator, _VideoDecodePool
 from pypaimon.table.row.video_keyframe_index import VideoKeyframeIndex
 
 
@@ -274,6 +274,12 @@ class PaimonDatasetReader(ABC):
             if isinstance(table_options, CoreOptions)
             else CoreOptions.READ_VIDEO_MAX_OPEN_DECODERS.default_value()
         )
+        self._video_decode_parallelism = (
+            table_options.read_video_decode_parallelism()
+            if isinstance(table_options, CoreOptions)
+            else CoreOptions.READ_VIDEO_DECODE_PARALLELISM.default_value()
+        )
+        self._video_decode_pool = _VideoDecodePool()
         self._video_collators = [
             VideoFrameCollator(
                 access,
@@ -448,13 +454,21 @@ class PaimonDatasetReader(ABC):
         video_windows = {} if self.image_transforms is not None else 
_decode_video_windows(
             plans, rows, getattr(self, "_video_collators", ()),
             self._features, self.return_uint8,
-            return_batch=return_batch, share_memory=share_memory)
+            return_batch=return_batch, share_memory=share_memory,
+            decode_pool=getattr(self, "_video_decode_pool", None),
+            decode_parallelism=getattr(
+                self, "_video_decode_parallelism",
+                CoreOptions.READ_VIDEO_DECODE_PARALLELISM.default_value()))
         for group in row_groups:
             for row in group.values():
                 for key in video_windows:
                     row.pop(key, None)
         _decode_video_rows(
-            row_groups, getattr(self, "_video_collators", ()))
+            row_groups, getattr(self, "_video_collators", ()),
+            decode_pool=getattr(self, "_video_decode_pool", None),
+            decode_parallelism=getattr(
+                self, "_video_decode_parallelism",
+                CoreOptions.READ_VIDEO_DECODE_PARALLELISM.default_value()))
         converted = {
             position: _torch_row(
                 row, self._features, self.return_uint8)
@@ -509,6 +523,9 @@ class PaimonDatasetReader(ABC):
         return self.get_items(indices)
 
     def close(self):
+        pool = getattr(self, "_video_decode_pool", None)
+        if pool is not None:
+            pool.close()
         first_error = None
         for collator in getattr(self, "_video_collators", ()):
             try:
@@ -1422,7 +1439,21 @@ def _attach_task_labels(rows, task_names, subtask_names):
 
 def _decode_video_windows(
         plans, rows, collators, features, return_uint8, *,
-        return_batch=False, share_memory=False):
+        return_batch=False, share_memory=False, decode_parallelism=1, 
decode_pool=None):
+    pool = decode_pool if decode_pool is not None else _VideoDecodePool()
+    try:
+        return _decode_video_windows_with_pool(
+            plans, rows, collators, features, return_uint8,
+            return_batch=return_batch, share_memory=share_memory,
+            decode_parallelism=decode_parallelism, 
executor=pool.get(decode_parallelism))
+    finally:
+        if decode_pool is None:
+            pool.close()
+
+
+def _decode_video_windows_with_pool(
+        plans, rows, collators, features, return_uint8, *,
+        return_batch, share_memory, decode_parallelism, executor):
     import torch
 
     tasks = [c for c in collators if c.video_column in plans[0]["windows"]]
@@ -1456,16 +1487,24 @@ def _decode_video_windows(
             requests.setdefault((payload, keyframe_index), []).append((
                 offset, [d.frame_index for d in window]))
 
+        def read_frames(decoder, windows):
+            get_frames = getattr(decoder, "get_frames_at", None)
+            if not callable(get_frames):
+                return None
+            indices = sorted({i for _, window in windows for i in window})
+            with torch.inference_mode(inference_enabled), \
+                    torch.set_grad_enabled(grad_enabled):
+                return windows, indices, get_frames(indices=indices).data
+
         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():
-                decoder = collator._decoder(payload, keyframe_index)
-                get_frames = getattr(decoder, "get_frames_at", None)
-                if not callable(get_frames):
+                torch.set_grad_enabled(grad_enabled), \
+                collator._map_decoders(
+                    requests, read_frames, decode_parallelism, executor) as 
decoded:
+            for result in decoded:
+                if result is None:
                     return key, None
-                indices = sorted({i for _, window in windows for i in window})
-                frames = get_frames(indices=indices).data
+                windows, indices, frames = result
                 if (not torch.is_tensor(frames) or frames.ndim != 4
                         or len(frames) != len(indices)):
                     raise ValueError("Video decoder must return one frame per 
index.")
@@ -1519,12 +1558,12 @@ def _decode_video_windows(
                             output[offset] = output[offset].float().div_(255)
         return key, output
 
-    if len(tasks) == 1:
-        decoded = [decode(tasks[0])]
+    if executor is None or len(tasks) == 1:
+        decoded = [decode(task) for task in tasks]
     else:
         with ThreadPoolExecutor(
-                max_workers=min(len(tasks), _MAX_VISUAL_WORKERS)) as executor:
-            decoded = list(executor.map(decode, tasks))
+                max_workers=min(len(tasks), _MAX_VISUAL_WORKERS)) as 
camera_executor:
+            decoded = list(camera_executor.map(decode, tasks))
     return {key: windows for key, windows in decoded if windows is not None}
 
 
@@ -2151,7 +2190,8 @@ def _identity(values):
     return values
 
 
-def _decode_video_rows(row_groups, collators):
+def _decode_video_rows(
+        row_groups, collators, decode_parallelism=_MAX_VISUAL_WORKERS, 
decode_pool=None):
     tasks = []
     for collator in collators:
         targets = []
@@ -2167,14 +2207,24 @@ def _decode_video_rows(row_groups, collators):
 
     if not tasks:
         return
-    if len(tasks) == 1:
-        decoded_groups = [tasks[0][0](tasks[0][2])]
+    if decode_parallelism == 1:
+        decoded_groups = [collator(rows) for collator, _, rows in tasks]
     else:
-        with ThreadPoolExecutor(
-                max_workers=min(
-                    len(tasks), _MAX_VISUAL_WORKERS)) as executor:
-            decoded_groups = list(executor.map(
-                lambda task: task[0](task[2]), tasks))
+        from concurrent.futures import wait
+
+        pool = decode_pool if decode_pool is not None else _VideoDecodePool()
+        futures = []
+        try:
+            executor = pool.get(decode_parallelism)
+            for collator, _, rows in tasks:
+                futures.append(executor.submit(collator, rows))
+            decoded_groups = [future.result() for future in futures]
+        finally:
+            for future in futures:
+                future.cancel()
+            wait(futures)
+            if decode_pool is None:
+                pool.close()
 
     for (collator, targets, _), decoded in zip(tasks, decoded_groups):
         for (rows, index), row in zip(targets, decoded):
diff --git a/paimon-python/pypaimon/multimodal/video.py 
b/paimon-python/pypaimon/multimodal/video.py
index 90489a0b0a..3d9eb812c4 100644
--- a/paimon-python/pypaimon/multimodal/video.py
+++ b/paimon-python/pypaimon/multimodal/video.py
@@ -19,8 +19,10 @@
 
 import io
 import os
-from collections import OrderedDict
+from collections import OrderedDict, deque
 from collections.abc import Mapping
+from concurrent.futures import ThreadPoolExecutor, wait
+from contextlib import contextmanager
 from fractions import Fraction
 from itertools import chain
 
@@ -28,6 +30,35 @@ from pypaimon.table.row.blob import Blob, 
VideoFrameDescriptor
 from pypaimon.table.row.video_keyframe_index import VideoKeyframeIndex
 
 
+class _VideoDecodePool:
+    """One lazily created decode pool per dataset and worker process."""
+
+    def __init__(self):
+        self._executor = None
+        self._parallelism = 0
+        self._pid = os.getpid()
+
+    def get(self, parallelism):
+        if self._pid != os.getpid():
+            # Never join threads inherited from the parent process.
+            self._executor = None
+            self._pid = os.getpid()
+        if self._executor is not None and self._parallelism != parallelism:
+            self.close()
+        if parallelism > 1 and self._executor is None:
+            self._executor = ThreadPoolExecutor(max_workers=parallelism)
+        self._parallelism = parallelism
+        return self._executor
+
+    def close(self):
+        executor, self._executor = self._executor, None
+        if executor is not None and self._pid == os.getpid():
+            executor.shutdown(wait=True)
+
+    def __getstate__(self):
+        return {"_executor": None, "_parallelism": 0, "_pid": None}
+
+
 def _encode_images_to_video(
         images,
         output_path,
@@ -225,6 +256,45 @@ class VideoFrameCollator:
         state["_owner_pid"] = None
         return state
 
+    @contextmanager
+    def _map_decoders(self, requests, decode, parallelism, executor=None):
+        self._ensure_process_local_cache()
+        limit = min(parallelism, self.max_open_videos, len(requests))
+        if executor is None:
+            yield (decode(self._decoder(*key), value)
+                   for key, value in requests.items())
+            return
+        items = iter(requests.items())
+        pending = deque()
+
+        def submit():
+            try:
+                key, value = next(items)
+            except StopIteration:
+                return
+            # Keep cache mutations ordered; pending decoders remain most 
recent.
+            decoder = self._decoder(*key)
+            pending.append(executor.submit(decode, decoder, value))
+
+        def results():
+            while pending:
+                future = pending[0]
+                value = future.result()
+                pending.popleft()
+                yield value
+                del value, future
+                submit()
+
+        try:
+            for _ in range(limit):
+                submit()
+            yield results()
+        finally:
+            for future in pending:
+                future.cancel()
+            # A failed or abandoned batch must finish before cache reuse/close.
+            wait(pending)
+
     def _decode_rows(self, rows):
         decoded = [None] * len(rows)
         grouped = OrderedDict()
diff --git a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py 
b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
index e3cfc4f6da..acd87cccb0 100644
--- a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
@@ -266,7 +266,7 @@ class LeRobotValidationTest(unittest.TestCase):
             0: {"camera_%d" % index: index for index in range(9)},
         }
         with patch(
-                "pypaimon.multimodal.lerobot.dataset.ThreadPoolExecutor",
+                "pypaimon.multimodal.video.ThreadPoolExecutor",
                 side_effect=executor):
             _decode_video_rows(
                 [rows], [Collator(index) for index in range(9)])
diff --git a/paimon-python/pypaimon/tests/video_decode_parallelism_test.py 
b/paimon-python/pypaimon/tests/video_decode_parallelism_test.py
new file mode 100644
index 0000000000..45a27930d1
--- /dev/null
+++ b/paimon-python/pypaimon/tests/video_decode_parallelism_test.py
@@ -0,0 +1,455 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import io
+import multiprocessing
+import os
+import pickle
+import threading
+import unittest
+from collections import OrderedDict
+from types import SimpleNamespace
+from unittest.mock import patch
+
+from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.common.options.options import Options
+from pypaimon.multimodal.video import VideoFrameCollator, _VideoDecodePool
+from pypaimon.table.row.blob import BlobDescriptor
+
+
+def identity(value):
+    return value
+
+
+def decode_worker(collator, pool, connection):
+    try:
+        requests = OrderedDict(
+            ((BlobDescriptor("child-%s" % i, 0, 1), None), i)
+            for i in range(2))
+        with patch("pypaimon.multimodal.video.Blob.from_file", 
side_effect=lambda *args:
+                   SimpleNamespace(new_input_stream=lambda: 
io.BytesIO(b"video"))):
+            with collator._map_decoders(
+                    requests, lambda stream, value: value, 2, pool.get(2)) as 
values:
+                result = list(values)
+        connection.send((result, collator._owner_pid == os.getpid()))
+    finally:
+        pool.close()
+        collator.close()
+        connection.close()
+
+
+class VideoDecodeParallelismTest(unittest.TestCase):
+    def collator(self, capacity=2):
+        return VideoFrameCollator(
+            SimpleNamespace(file_io="test-file-io"), video_column="camera",
+            decoder_factory=identity, decode_fn=identity,
+            max_open_videos=capacity)
+
+    def requests(self, count=8):
+        return OrderedDict(
+            ((BlobDescriptor("video-%s" % i, 0, 1), None), i)
+            for i in range(count))
+
+    def streams(self, *args):
+        return SimpleNamespace(new_input_stream=lambda: io.BytesIO(b"video"))
+
+    def test_shared_limit_across_cameras_and_eviction(self):
+        from concurrent.futures import ThreadPoolExecutor
+
+        pool = _VideoDecodePool()
+        executor = pool.get(2)
+        collators = [self.collator() for _ in range(3)]
+        active = [0, 0]
+        lock = threading.Lock()
+        barrier = threading.Barrier(2)
+
+        def decode(stream, value):
+            with lock:
+                active[0] += 1
+                active[1] = max(active)
+            try:
+                barrier.wait(timeout=5)
+                self.assertFalse(stream.closed)
+                return value
+            finally:
+                with lock:
+                    active[0] -= 1
+
+        def camera(collator, count):
+            with collator._map_decoders(self.requests(count), decode, 2, 
executor) as values:
+                return list(values)
+
+        try:
+            with patch("pypaimon.multimodal.video.Blob.from_file", 
self.streams), \
+                    ThreadPoolExecutor(max_workers=3) as cameras:
+                # One camera has a single payload; it must also share the 
budget.
+                futures = [cameras.submit(camera, c, n)
+                           for c, n in zip(collators, (1, 3, 8))]
+                self.assertEqual([f.result() for f in futures],
+                                 [list(range(n)) for n in (1, 3, 8)])
+            self.assertEqual(active, [0, 2])
+            self.assertIs(executor, pool.get(2))
+        finally:
+            pool.close()
+            for collator in collators:
+                streams = list(collator._decoders.values())
+                collator.close()
+                self.assertTrue(all(stream.closed for _, stream in streams))
+        self.assertTrue(executor._shutdown)
+
+    def test_error_drains_running_decodes(self):
+        pool = _VideoDecodePool()
+        executor = pool.get(2)
+        collator = self.collator()
+        barrier = threading.Barrier(2)
+        started = threading.Event()
+        release = threading.Event()
+        finished = threading.Event()
+        errors = []
+
+        def decode(stream, value):
+            barrier.wait(timeout=5)
+            if value == 0:
+                raise ValueError("decode failed")
+            started.set()
+            self.assertTrue(release.wait(5))
+            self.assertFalse(stream.closed)
+            return value
+
+        def consume():
+            try:
+                with collator._map_decoders(self.requests(2), decode, 2, 
executor) as values:
+                    list(values)
+            except Exception as error:
+                errors.append(error)
+            finally:
+                finished.set()
+
+        with patch("pypaimon.multimodal.video.Blob.from_file", self.streams):
+            thread = threading.Thread(target=consume)
+            thread.start()
+            try:
+                self.assertTrue(started.wait(5))
+                self.assertFalse(finished.is_set())
+            finally:
+                release.set()
+                thread.join(5)
+                pool.close()
+                collator.close()
+        self.assertTrue(finished.is_set())
+        self.assertEqual(str(errors[0]), "decode failed")
+
+    def test_serial_and_pickle(self):
+        pool = _VideoDecodePool()
+        self.assertIsNone(pool.get(1))
+        collator = self.collator(1)
+        owner = threading.get_ident()
+        with patch("pypaimon.multimodal.video.Blob.from_file", self.streams):
+            with collator._map_decoders(
+                    self.requests(3), lambda stream, value: 
threading.get_ident(), 1) as values:
+                self.assertEqual(list(values), [owner] * 3)
+        pool.get(2).submit(identity, 1).result()
+        restored_collator, restored_pool = 
pickle.loads(pickle.dumps((collator, pool)))
+        self.assertIsNone(restored_pool._executor)
+        self.assertFalse(restored_collator._decoders)
+        pool.close()
+        collator.close()
+        restored_pool.close()
+        restored_collator.close()
+
+    @unittest.skipUnless(hasattr(os, "fork"), "requires fork")
+    def test_fork_discards_inherited_executor(self):
+        pool = _VideoDecodePool()
+        executor = pool.get(2)
+        try:
+            with patch("pypaimon.multimodal.video.os.getpid", 
return_value=-1), \
+                    patch.object(executor, "shutdown", 
side_effect=AssertionError("inherited pool")):
+                child_executor = pool.get(2)
+                self.assertIsNot(executor, child_executor)
+                pool.close()
+                self.assertTrue(child_executor._shutdown)
+        finally:
+            executor.shutdown(wait=True)
+
+    def test_workers_rebuild_pool(self):
+        collator = self.collator()
+        pool = _VideoDecodePool()
+        pool.get(2).submit(identity, 1).result()
+        try:
+            for method in ("spawn", "fork"):
+                if method not in multiprocessing.get_all_start_methods():
+                    continue
+                with self.subTest(method=method):
+                    ctx = multiprocessing.get_context(method)
+                    receive, send = ctx.Pipe(duplex=False)
+                    worker = ctx.Process(target=decode_worker, args=(collator, 
pool, send))
+                    worker.start()
+                    send.close()
+                    try:
+                        self.assertTrue(receive.poll(15), "worker did not 
complete")
+                        self.assertEqual(receive.recv(), ([0, 1], True))
+                        worker.join(5)
+                        self.assertEqual(worker.exitcode, 0)
+                    finally:
+                        if worker.is_alive():
+                            worker.terminate()
+                            worker.join(5)
+                        receive.close()
+        finally:
+            pool.close()
+            collator.close()
+
+    def test_parallel_windows_match_serial_and_preserve_torch_mode(self):
+        try:
+            import torch
+        except ImportError as error:
+            self.skipTest(str(error))
+        from pypaimon.multimodal.lerobot.dataset import _decode_video_windows
+        from pypaimon.table.row.blob import VideoFrameDescriptor
+
+        pixels = torch.arange(4 * 4 * 5 * 3).reshape(4, 4, 5, 
3).to(torch.uint8)
+        frames = pixels.permute(0, 3, 1, 2)
+        rows = {i: {"camera": VideoFrameDescriptor(
+            "video-%s" % (i // 4), 0, 1, i % 4, -1, 0).serialize()}
+            for i in range(12)}
+        plans = [{"windows": {"camera": w}} for w in (
+            [0, 0, 2], [4, 5, 6], [8, 9, 10], [0, 1, 3])]
+        features = {"camera": {"dtype": "video", "shape": [4, 5, 3]}}
+        modes = []
+
+        def read(indices):
+            modes.append((torch.is_grad_enabled(), 
torch.is_inference_mode_enabled()))
+            # Preserve the noncontiguous layout returned by TorchCodec.
+            return SimpleNamespace(data=pixels[indices].permute(0, 3, 1, 2))
+
+        collator = self.collator(2)
+        decoder = SimpleNamespace(get_frames_at=read)
+        try:
+            with patch.object(collator, "_decoder", return_value=decoder), \
+                    patch("torch.get_num_threads", return_value=1), \
+                    torch.inference_mode():
+                for uint8 in (True, False):
+                    for batch in (True, False):
+                        with self.subTest(uint8=uint8, batch=batch):
+                            kwargs = dict(return_batch=batch, 
share_memory=batch)
+                            expected = _decode_video_windows(
+                                plans, rows, [collator], features, uint8,
+                                decode_parallelism=1, **kwargs)["camera"]
+                            actual = _decode_video_windows(
+                                plans, rows, [collator], features, uint8,
+                                decode_parallelism=4, **kwargs)["camera"]
+                            if not batch:
+                                expected, actual = torch.stack(expected), 
torch.stack(actual)
+                            self.assertTrue(torch.equal(expected, actual))
+                            self.assertTrue(actual.is_contiguous())
+                            if batch:
+                                self.assertTrue(actual.is_shared())
+                            saved = actual[3].clone()
+                            actual[0].zero_()
+                            self.assertTrue(torch.equal(saved, actual[3]))
+            self.assertTrue(modes)
+            self.assertEqual(set(modes), {(False, True)})
+            self.assertTrue(torch.equal(frames, pixels.permute(0, 3, 1, 2)))
+        finally:
+            collator.close()
+
+    def test_video_windows_share_worker_pool_across_cameras(self):
+        try:
+            import torch
+        except ImportError as error:
+            self.skipTest(str(error))
+        from contextlib import ExitStack
+        from pypaimon.multimodal.lerobot.dataset import _decode_video_windows
+        from pypaimon.table.row.blob import VideoFrameDescriptor
+
+        pool = _VideoDecodePool()
+        collators = [self.collator(1) for _ in range(3)]
+        names = ["camera-%s" % i for i in range(3)]
+        for collator, name in zip(collators, names):
+            collator.video_column = name
+        rows = {0: {name: VideoFrameDescriptor(name, 0, 1, 0, -1, 
0).serialize()
+                    for name in names}}
+        plans = [{"windows": {name: [0] for name in names}}]
+        features = {name: {"dtype": "video", "shape": [4, 5, 3]} for name in 
names}
+        threads = set()
+        lock = threading.Lock()
+
+        def read(indices):
+            with lock:
+                threads.add(threading.get_ident())
+            return SimpleNamespace(data=torch.ones((len(indices), 3, 4, 5), 
dtype=torch.uint8))
+
+        try:
+            with ExitStack() as stack:
+                stack.enter_context(patch("torch.get_num_threads", 
return_value=1))
+                for collator in collators:
+                    stack.enter_context(patch.object(
+                        collator, "_decoder", 
return_value=SimpleNamespace(get_frames_at=read)))
+                for parallelism in (1, 2):
+                    threads.clear()
+                    result = _decode_video_windows(
+                        plans, rows, collators, features, True, 
return_batch=True,
+                        decode_parallelism=parallelism, decode_pool=pool)
+                    self.assertEqual(set(result), set(names))
+                    self.assertLessEqual(len(threads), parallelism)
+                    for value in result.values():
+                        self.assertTrue(torch.equal(value, 
torch.ones_like(value)))
+                    if parallelism == 1:
+                        self.assertEqual(threads, {threading.get_ident()})
+                    else:
+                        self.assertNotIn(threading.get_ident(), threads)
+                executor = pool.get(2)
+                _decode_video_windows(plans, rows, collators, features, True,
+                                      decode_parallelism=2, decode_pool=pool)
+                self.assertIs(executor, pool.get(2))
+        finally:
+            pool.close()
+            for collator in collators:
+                collator.close()
+
+    def test_dataloader_fallback_honors_decode_limit(self):
+        try:
+            import torch
+        except ImportError as error:
+            self.skipTest(str(error))
+        import time
+        import pyarrow as pa
+        import pypaimon.multimodal as pm
+
+        names = ("camera_a", "camera_b")
+        features = {name: {"dtype": "int64", "shape": [1]}
+                    for name in ("index", "episode_index", "frame_index", 
"task_index")}
+        features["timestamp"] = {"dtype": "float32", "shape": [1]}
+        features.update({name: {"dtype": "video", "shape": [2, 2, 3]} for name 
in names})
+        metadata = {
+            "repo_id": "test/fallback",
+            "info": {"codebase_version": "v3.0", "fps": 10, "total_frames": 1,
+                     "total_episodes": 1, "total_tasks": 1, "features": 
features},
+            "episodes": [{"episode_index": 0, "dataset_from_index": 0,
+                          "dataset_to_index": 1, "length": 1, "tasks": 
["pick"]}],
+            "tasks": ["pick"], "stats": {},
+        }
+
+        class Reader(pm.PaimonDatasetReader):
+            def read_indices(self, indices, columns):
+                row = {name: 0 for name in features}
+                row.update({name: b"video" for name in names})
+                return pa.Table.from_pylist(
+                    [{name: row[name] for name in columns}], 
schema=self.schema)
+
+        active = [0, 0]
+        lock = threading.Lock()
+
+        class Collator:
+            def __init__(self, name):
+                self.video_column = self.output_column = name
+
+            def __call__(self, rows):
+                with lock:
+                    active[0] += 1
+                    active[1] = max(active)
+                try:
+                    time.sleep(0.02)
+                    return [dict(row, **{self.output_column: torch.ones(
+                        (3, 2, 2), dtype=torch.uint8)}) for row in rows]
+                finally:
+                    with lock:
+                        active[0] -= 1
+
+            def close(self):
+                pass
+
+        for transforms in (None, identity):
+            with self.subTest(transforms=transforms):
+                reader = Reader(metadata, file_io="test-file-io",
+                                delta_timestamps={name: [0.0] for name in 
names},
+                                image_transforms=transforms, return_uint8=True)
+                reader._video_collators = [Collator(name) for name in names]
+                reader._video_decode_parallelism = 1
+                dataset = pm.PaimonLeRobotDataset(reader)
+                active[:] = [0, 0]
+                try:
+                    with patch("torch.get_num_threads", return_value=2):
+                        batch = next(iter(dataset.to_dataloader(batch_size=1, 
num_workers=0)))
+                    self.assertEqual(active, [0, 1])
+                    for name in names:
+                        self.assertTrue(torch.equal(batch[name], 
torch.ones_like(batch[name])))
+                finally:
+                    dataset.close()
+
+    def test_real_pyav_payloads_match_serial(self):
+        try:
+            import av
+            import numpy as np
+            import torch
+        except ImportError as error:
+            self.skipTest(str(error))
+        from functools import partial
+        from pypaimon.multimodal.lerobot.dataset import (
+            _decode_video_windows, _open_video_decoder)
+        from pypaimon.table.row.blob import VideoFrameDescriptor
+
+        videos = {}
+        for video in range(3):
+            target = io.BytesIO()
+            with av.open(target, mode="w", format="mp4") as container:
+                stream = container.add_stream("mpeg4", rate=30)
+                stream.width, stream.height = 16, 16
+                stream.pix_fmt = "yuv420p"
+                for frame in range(8):
+                    image = np.full((16, 16, 3), video * 60 + frame * 5, 
dtype=np.uint8)
+                    for packet in 
stream.encode(av.VideoFrame.from_ndarray(image, format="rgb24")):
+                        container.mux(packet)
+                for packet in stream.encode():
+                    container.mux(packet)
+            videos["video-%s" % video] = target.getvalue()
+        rows = {i: {"camera": VideoFrameDescriptor(
+            "video-%s" % (i // 8), 0, len(videos["video-%s" % (i // 8)]),
+            i % 8, -1, 0).serialize()} for i in range(24)}
+        plans = [{"windows": {"camera": indices}} for indices in (
+            [0, 0, 3], [8, 9, 11], [16, 18, 19], [1, 2, 4])]
+        outputs = []
+
+        def blob(file_io, uri, offset, length):
+            return SimpleNamespace(new_input_stream=lambda: 
io.BytesIO(videos[uri]))
+
+        for parallelism in (1, 4):
+            collator = VideoFrameCollator(
+                SimpleNamespace(file_io="test"), video_column="camera",
+                decoder_factory=partial(_open_video_decoder, backend="pyav"),
+                decode_fn=identity, max_open_videos=2)
+            try:
+                with patch("pypaimon.multimodal.video.Blob.from_file", blob), \
+                        patch("torch.get_num_threads", return_value=1):
+                    outputs.append(_decode_video_windows(
+                        plans, rows, [collator],
+                        {"camera": {"dtype": "video", "shape": [16, 16, 3]}}, 
True,
+                        return_batch=True, 
decode_parallelism=parallelism)["camera"])
+            finally:
+                collator.close()
+        self.assertTrue(torch.equal(*outputs))
+
+    def test_options(self):
+        
self.assertEqual(CoreOptions(Options({})).read_video_decode_parallelism(), 16)
+        self.assertEqual(CoreOptions(Options({"read.video.decode-parallelism": 
"1"}))
+                         .read_video_decode_parallelism(), 1)
+        self.assertEqual(CoreOptions(Options({"read.video.decode-parallelism": 
"4"}))
+                         .read_video_decode_parallelism(), 4)
+        for value in (0, -1, True, 1.5, "no", "1.5"):
+            with self.subTest(value=value), self.assertRaises(ValueError):
+                CoreOptions(Options({"read.video.decode-parallelism": value})) 
\
+                    .read_video_decode_parallelism()

Reply via email to