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 dab9a1801b [python] Make LeRobot PyAV codec threads configurable 
(#10796)
dab9a1801b is described below

commit dab9a1801b830750539154ea48dedc461891d2ae
Author: XiaoHongbo <[email protected]>
AuthorDate: Sun Oct 25 10:45:53 2026 +0800

    [python] Make LeRobot PyAV codec threads configurable (#10796)
---
 docs/docs/pypaimon/lerobot.md                      |  4 ++
 .../pypaimon/common/options/core_options.py        | 21 ++++++++
 .../pypaimon/multimodal/lerobot/dataset.py         | 22 +++++---
 .../pypaimon/tests/multimodal_lerobot_test.py      |  6 ++-
 .../tests/video_decode_parallelism_test.py         | 62 ++++++++++++++++++++++
 5 files changed, 108 insertions(+), 7 deletions(-)

diff --git a/docs/docs/pypaimon/lerobot.md b/docs/docs/pypaimon/lerobot.md
index a3c0cfcc78..caca02fee7 100644
--- a/docs/docs/pypaimon/lerobot.md
+++ b/docs/docs/pypaimon/lerobot.md
@@ -261,6 +261,10 @@ Each open decoder uses memory and a file handle, so usage 
grows with cameras and
 `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.
+`read.video.codec-threads` controls FFmpeg threads per PyAV decoder (default: 
1),
+avoiding nested codec parallelism with multiple workers and concurrent videos.
+Set it to 0 for automatic selection. It does not affect TorchCodec.
+
 `read.video.decoders-per-payload` (default: 4) also splits single-video 
windows across
 independent cached decoders, within those limits. Set it to 1 to disable this 
splitting;
 the reader automatically reduces the decoder count when overlapping windows 
would add
diff --git a/paimon-python/pypaimon/common/options/core_options.py 
b/paimon-python/pypaimon/common/options/core_options.py
index 32b8fdb45a..d462943b93 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -1180,6 +1180,15 @@ class CoreOptions:
             "PyPaimon LeRobot Dataset worker.")
     )
 
+    READ_VIDEO_CODEC_THREADS: ConfigOption[int] = (
+        ConfigOptions.key("read.video.codec-threads")
+        .int_type()
+        .default_value(1)
+        .with_description(
+            "FFmpeg threads per PyAV video decoder in a LeRobot worker. "
+            "0 keeps automatic selection; 1 avoids nested codec parallelism.")
+    )
+
     READ_VIDEO_DECODERS_PER_PAYLOAD: ConfigOption[int] = (
         ConfigOptions.key("read.video.decoders-per-payload")
         .int_type()
@@ -1962,6 +1971,18 @@ class CoreOptions:
     def read_video_decoders_per_payload(self) -> int:
         return 
self._positive_video_option(CoreOptions.READ_VIDEO_DECODERS_PER_PAYLOAD)
 
+    def read_video_codec_threads(self) -> int:
+        option = CoreOptions.READ_VIDEO_CODEC_THREADS
+        raw = self.options.to_map().get(option.key())
+        try:
+            value = self.options.get(option)
+            if isinstance(raw, (bool, float)) or value < 0:
+                raise ValueError()
+        except (TypeError, ValueError) as error:
+            raise ValueError(
+                "%s must be a non-negative integer." % option.key()) from error
+        return value
+
     def _positive_video_option(self, option) -> int:
         raw = self.options.to_map().get(option.key())
         if isinstance(raw, (bool, float)):
diff --git a/paimon-python/pypaimon/multimodal/lerobot/dataset.py 
b/paimon-python/pypaimon/multimodal/lerobot/dataset.py
index 929b83d54d..8df8bd50e3 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/dataset.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/dataset.py
@@ -285,13 +285,19 @@ class PaimonDatasetReader(ABC):
             if isinstance(table_options, CoreOptions)
             else CoreOptions.READ_VIDEO_DECODERS_PER_PAYLOAD.default_value()
         )
+        codec_threads = (
+            table_options.read_video_codec_threads()
+            if isinstance(table_options, CoreOptions)
+            else CoreOptions.READ_VIDEO_CODEC_THREADS.default_value()
+        )
         self._video_decode_pool = _VideoDecodePool()
         self._video_collators = [
             VideoFrameCollator(
                 access,
                 video_column=key,
                 decoder_factory=partial(
-                    _open_video_decoder, backend=self.video_backend),
+                    _open_video_decoder, backend=self.video_backend,
+                    codec_threads=codec_threads),
                 decode_batch_fn=_decode_video_frames,
                 output_column=key,
                 collate_fn=_identity,
@@ -1742,13 +1748,14 @@ def _video_decoder_fallback_errors():
     return errors if ffmpeg_error is None else errors + (ffmpeg_error,)
 
 
-def _open_video_decoder(stream, backend=None):
+def _open_video_decoder(stream, backend=None, codec_threads=0):
+    pyav_options = {"thread_count": codec_threads} if codec_threads else {}
     keyframe_index = getattr(stream, "video_keyframe_index", None)
     if not isinstance(keyframe_index, VideoKeyframeIndex):
         keyframe_index = None
     if backend is None and keyframe_index is not None:
         try:
-            decoder = _PyAVVideoDecoder(stream, keyframe_index)
+            decoder = _PyAVVideoDecoder(stream, keyframe_index, **pyav_options)
         except _video_decoder_fallback_errors():
             stream.seek(0)
         else:
@@ -1760,8 +1767,8 @@ def _open_video_decoder(stream, backend=None):
             if backend == "torchcodec":
                 raise
             stream.seek(0)
-    return _PyAVVideoDecoder(stream) if keyframe_index is None \
-        else _PyAVVideoDecoder(stream, keyframe_index)
+    return _PyAVVideoDecoder(stream, **pyav_options) if keyframe_index is None 
\
+        else _PyAVVideoDecoder(stream, keyframe_index, **pyav_options)
 
 
 def _open_torchcodec_decoder(stream):
@@ -1958,7 +1965,7 @@ class _PyAVVideoDecoder:
     # Reuse common overlapping delta windows without retaining a whole video.
     _FRAME_CACHE_SIZE = 8
 
-    def __init__(self, stream, keyframe_index=None):
+    def __init__(self, stream, keyframe_index=None, thread_count=0):
         try:
             import av
         except ImportError as error:
@@ -1968,6 +1975,7 @@ class _PyAVVideoDecoder:
             ) from error
         self._source = stream
         self._keyframe_index = keyframe_index
+        self._thread_count = thread_count
         self._anchor_ordinals = (
             [entry[0] for entry in keyframe_index.keyframes]
             if keyframe_index is not None else [])
@@ -1981,6 +1989,7 @@ class _PyAVVideoDecoder:
         if keyframe_index is None:
             self._container = av.open(stream)
             self._stream = self._container.streams.video[0]
+            self._stream.thread_count = self._thread_count
             self._frames = iter(self._container.decode(self._stream))
 
     def __getitem__(self, index):
@@ -2076,6 +2085,7 @@ class _PyAVVideoDecoder:
             source.prefetch(self._keyframe_index.metadata_ranges)
             with av.open(source) as container:
                 stream = container.streams.video[0]
+                stream.thread_count = self._thread_count
                 for anchor, targets in sorted(groups.items()):
                     unused_groups, ranges = self._indexed_plan(targets)
                     source.prefetch(ranges)
diff --git a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py 
b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
index b718ca9d7f..331a7ef57c 100644
--- a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
@@ -730,7 +730,8 @@ class LeRobotValidationTest(unittest.TestCase):
 
         for configured, expected in ((None, 16), ("2", 2)):
             with self.subTest(configured=configured):
-                options = {} if configured is None else {option: configured}
+                options = {} if configured is None else {
+                    option: configured, "read.video.codec-threads": configured}
                 frames = SimpleNamespace(
                     options=CoreOptions(Options(options)))
                 rows = SimpleNamespace(
@@ -745,6 +746,9 @@ class LeRobotValidationTest(unittest.TestCase):
                 try:
                     self.assertEqual([expected, expected], [
                         c.max_open_videos for c in 
dataset.reader._video_collators])
+                    self.assertEqual([1 if configured is None else 2] * 2, [
+                        c.decoder_factory.keywords["codec_threads"]
+                        for c in dataset.reader._video_collators])
                 finally:
                     dataset.reader.close()
 
diff --git a/paimon-python/pypaimon/tests/video_decode_parallelism_test.py 
b/paimon-python/pypaimon/tests/video_decode_parallelism_test.py
index dfeb41bbb5..05a9025afd 100644
--- a/paimon-python/pypaimon/tests/video_decode_parallelism_test.py
+++ b/paimon-python/pypaimon/tests/video_decode_parallelism_test.py
@@ -434,6 +434,68 @@ class VideoDecodeParallelismTest(unittest.TestCase):
             torch.set_default_device(original_device)
             collator.close()
 
+    def test_codec_thread_option(self):
+        self.assertEqual(1, 
CoreOptions(Options({})).read_video_codec_threads())
+        for value in (0, 1, 4, "1"):
+            self.assertEqual(int(value), CoreOptions(Options({
+                "read.video.codec-threads": 
value})).read_video_codec_threads())
+        for value in (-1, True, 1.5, "bad"):
+            with self.subTest(value=value), self.assertRaises(ValueError):
+                CoreOptions(Options({"read.video.codec-threads": value})) \
+                    .read_video_codec_threads()
+
+    def test_codec_threads_preserve_real_indexed_and_scan_frames(self):
+        try:
+            import av
+            import numpy as np
+            import torch
+        except ImportError as error:
+            self.skipTest(str(error))
+        from pypaimon.multimodal.lerobot.dataset import _open_video_decoder
+        from pypaimon.table.row.video_keyframe_index import VideoKeyframeIndex
+
+        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"
+            stream.codec_context.gop_size = 4
+            for value in range(12):
+                image = np.full((16, 16, 3), value * 15, 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)
+        payload = target.getvalue()
+        index = VideoKeyframeIndex.inspect(io.BytesIO(payload), len(payload))
+        outputs = []
+        for indexed in (False, True):
+            for threads in (0, 1, 2):
+                source = io.BytesIO(payload)
+                if indexed:
+                    source.video_keyframe_index = index
+                    source.video_length = len(payload)
+                decoder = _open_video_decoder(source, backend="pyav", 
codec_threads=threads)
+                try:
+                    if indexed:
+                        decode_group = decoder._decode_indexed_group
+
+                        def checked_group(container, stream, *args):
+                            self.assertEqual(threads, stream.thread_count)
+                            return decode_group(container, stream, *args)
+
+                        with patch.object(decoder, '_decode_indexed_group', 
checked_group):
+                            frames = [decoder[i] for i in (9, 1, 7)]
+                    else:
+                        self.assertEqual(threads, decoder._stream.thread_count)
+                        frames = [decoder[i] for i in (9, 1, 7)]
+                    outputs.append(torch.stack(frames))
+                finally:
+                    decoder.close()
+                    source.close()
+        for output in outputs[1:]:
+            self.assertTrue(torch.equal(outputs[0], output))
+
     def test_real_pyav_payloads_match_serial(self):
         try:
             import av

Reply via email to