From a201594a90e474f7ce3769e75521c9440ad9e422 Mon Sep 17 00:00:00 2001 From: Matteo Benedetto Date: Tue, 24 Mar 2026 00:47:32 +0100 Subject: [PATCH] perf: reduce NV12 per-frame copies from 5 to 2 via single from_buffer_copy + byref offset --- .../player/gstreamer_backend.py | 58 +++++++++++-------- tests/benchmark_nv12_decode.py | 17 ++++-- 2 files changed, 44 insertions(+), 31 deletions(-) diff --git a/src/r36s_dlna_browser/player/gstreamer_backend.py b/src/r36s_dlna_browser/player/gstreamer_backend.py index 30a5247..ab0fa2b 100644 --- a/src/r36s_dlna_browser/player/gstreamer_backend.py +++ b/src/r36s_dlna_browser/player/gstreamer_backend.py @@ -147,8 +147,11 @@ class _Frame: pitch: int pixels: bytes pixel_format: str = "BGRA" # "BGRA" or "NV12" - # For NV12: pitch is the Y-plane stride; uv_pixels is the interleaved UV plane. - uv_pixels: bytes | None = None + # For NV12: pixels holds the FULL raw buffer (Y + interleaved UV); y_size is + # the byte offset where the UV plane starts. uv_pitch is the UV plane stride. + # Storing the whole buffer in one object avoids two separate bytes slices (each + # a copy) and lets render() do a single from_buffer_copy instead of two. + y_size: int = 0 uv_pitch: int = 0 @@ -243,25 +246,30 @@ class GStreamerBackend(PlayerBackend): self._texture_format = frame.pixel_format if self._frame_dirty: - if frame.pixel_format == "NV12" and frame.uv_pixels is not None: - # Zero-copy NV12 path: upload Y and UV planes separately. - # SDL_UpdateNVTexture avoids a full BGRA conversion on CPU. - # Use named c_ubyte arrays so the buffers stay alive for the - # duration of the C call (ctypes.cast of a temporary would produce - # a dangling pointer after CPython reference-counts the temp away). + if frame.pixel_format == "NV12" and frame.y_size > 0: + # NV12 upload via SDL_UpdateNVTexture. + # ONE from_buffer_copy of the full Y+UV buffer, then use + # ctypes.byref(arr, offset) to address Y at 0 and UV at y_size. + # This avoids the two extra bytes slices that were previously + # created in _on_new_sample, cutting per-frame copies from 5 to 2. try: - y_arr = (ctypes.c_ubyte * len(frame.pixels)).from_buffer_copy(frame.pixels) - uv_arr = (ctypes.c_ubyte * len(frame.uv_pixels)).from_buffer_copy(frame.uv_pixels) + raw_buf = frame.pixels + arr = (ctypes.c_ubyte * len(raw_buf)).from_buffer_copy(raw_buf) + y_ptr = ctypes.cast(arr, ctypes.POINTER(ctypes.c_ubyte)) + uv_ptr = ctypes.cast( + ctypes.byref(arr, frame.y_size), + ctypes.POINTER(ctypes.c_ubyte), + ) log.debug( - "SDL_UpdateNVTexture: %dx%d y_len=%d uv_len=%d pitch=%d uv_pitch=%d", + "SDL_UpdateNVTexture: %dx%d buf=%d y_size=%d pitch=%d uv_pitch=%d", frame.width, frame.height, - len(frame.pixels), len(frame.uv_pixels), + len(raw_buf), frame.y_size, frame.pitch, frame.uv_pitch, ) result = sdl2.SDL_UpdateNVTexture( self._texture, None, - y_arr, frame.pitch, - uv_arr, frame.uv_pitch, + y_ptr, frame.pitch, + uv_ptr, frame.uv_pitch, ) except Exception: log.error( @@ -458,25 +466,25 @@ class GStreamerBackend(PlayerBackend): if fmt_str == "NV12": # NV12: Y plane (stride[0]) followed immediately by interleaved UV plane (stride[1]). - y_size = int(info.stride[0]) * height - uv_size = int(info.stride[1]) * (height // 2) - raw = buffer.extract_dup(0, buffer.get_size()) - pixels = raw[:y_size] - uv_pixels = raw[y_size:y_size + uv_size] - pitch = int(info.stride[0]) + # Store the WHOLE raw buffer in pixels without slicing — slicing bytes + # creates two extra copies (2 MB + 1 MB) that we can avoid. render() + # uses a single from_buffer_copy of the full buffer and ctypes.byref to + # address the UV plane at the y_size byte offset. + pitch = int(info.stride[0]) uv_pitch = int(info.stride[1]) + y_size = pitch * height + raw = buffer.extract_dup(0, buffer.get_size()) if self._frame_count == 0: - buf_total = buffer.get_size() log.info( "First NV12 frame: %dx%d y_pitch=%d uv_pitch=%d " - "y_size=%d uv_size=%d buf_total=%d", - width, height, pitch, uv_pitch, y_size, uv_size, buf_total, + "y_size=%d buf_total=%d", + width, height, pitch, uv_pitch, y_size, len(raw), ) frame = _Frame( width=width, height=height, - pitch=pitch, pixels=pixels, + pitch=pitch, pixels=raw, pixel_format="NV12", - uv_pixels=uv_pixels, uv_pitch=uv_pitch, + y_size=y_size, uv_pitch=uv_pitch, ) else: pitch = int(info.stride[0]) if info.stride else width * 4 diff --git a/tests/benchmark_nv12_decode.py b/tests/benchmark_nv12_decode.py index 7d34aed..51679dd 100644 --- a/tests/benchmark_nv12_decode.py +++ b/tests/benchmark_nv12_decode.py @@ -11,7 +11,11 @@ mppvideodec auto-selected) and reports: • A/V sync drift (video PTS vs pipeline clock position) • from_buffer_copy() time per frame (CPU copy cost) -Run on device: +Run on device (must use same env as the app): + export LD_LIBRARY_PATH=/home/ark/miniconda3/envs/r36s-dlna-browser/lib + export GST_PLUGIN_PATH=/usr/lib/aarch64-linux-gnu/gstreamer-1.0 + export LD_PRELOAD=/usr/lib/aarch64-linux-gnu/libgomp.so.1 + export PYTHONPATH=/home/ark/R36SHack/src /home/ark/miniconda3/envs/r36s-dlna-browser/bin/python \\ /home/ark/R36SHack/tests/benchmark_nv12_decode.py [URL] @@ -186,12 +190,13 @@ def _on_sample(sink) -> Gst.FlowReturn: if fmt_str == "NV12": y_size = int(info.stride[0]) * int(info.height) - uv_size = int(info.stride[1]) * (int(info.height) // 2) - # Simulate named c_ubyte array creation (the actual upload path) + # Simulate the single from_buffer_copy the app now does: + # ONE copy of the full Y+UV buffer, then ctypes.byref for the UV offset. t1 = time.monotonic() - y_arr = (ctypes.c_ubyte * len(raw[:y_size])).from_buffer_copy(raw[:y_size]) - uv_arr = (ctypes.c_ubyte * len(raw[y_size:y_size+uv_size])).from_buffer_copy(raw[y_size:y_size+uv_size]) - copy_us = (time.monotonic() - t1) * 1e6 # override with NV12-specific copy + arr = (ctypes.c_ubyte * len(raw)).from_buffer_copy(raw) + y_ptr = ctypes.cast(arr, ctypes.POINTER(ctypes.c_ubyte)) + uv_ptr = ctypes.cast(ctypes.byref(arr, y_size), ctypes.POINTER(ctypes.c_ubyte)) + copy_us = (time.monotonic() - t1) * 1e6 with stats.lock: stats.total_frames += 1