perf: reduce NV12 per-frame copies from 5 to 2 via single from_buffer_copy + byref offset

This commit is contained in:
Matteo Benedetto
2026-03-24 00:47:32 +01:00
parent ecdbf5eb04
commit a201594a90
2 changed files with 44 additions and 31 deletions
@@ -147,8 +147,11 @@ class _Frame:
pitch: int pitch: int
pixels: bytes pixels: bytes
pixel_format: str = "BGRA" # "BGRA" or "NV12" pixel_format: str = "BGRA" # "BGRA" or "NV12"
# For NV12: pitch is the Y-plane stride; uv_pixels is the interleaved UV plane. # For NV12: pixels holds the FULL raw buffer (Y + interleaved UV); y_size is
uv_pixels: bytes | None = None # 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 uv_pitch: int = 0
@@ -243,25 +246,30 @@ class GStreamerBackend(PlayerBackend):
self._texture_format = frame.pixel_format self._texture_format = frame.pixel_format
if self._frame_dirty: if self._frame_dirty:
if frame.pixel_format == "NV12" and frame.uv_pixels is not None: if frame.pixel_format == "NV12" and frame.y_size > 0:
# Zero-copy NV12 path: upload Y and UV planes separately. # NV12 upload via SDL_UpdateNVTexture.
# SDL_UpdateNVTexture avoids a full BGRA conversion on CPU. # ONE from_buffer_copy of the full Y+UV buffer, then use
# Use named c_ubyte arrays so the buffers stay alive for the # ctypes.byref(arr, offset) to address Y at 0 and UV at y_size.
# duration of the C call (ctypes.cast of a temporary would produce # This avoids the two extra bytes slices that were previously
# a dangling pointer after CPython reference-counts the temp away). # created in _on_new_sample, cutting per-frame copies from 5 to 2.
try: try:
y_arr = (ctypes.c_ubyte * len(frame.pixels)).from_buffer_copy(frame.pixels) raw_buf = frame.pixels
uv_arr = (ctypes.c_ubyte * len(frame.uv_pixels)).from_buffer_copy(frame.uv_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( 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, frame.width, frame.height,
len(frame.pixels), len(frame.uv_pixels), len(raw_buf), frame.y_size,
frame.pitch, frame.uv_pitch, frame.pitch, frame.uv_pitch,
) )
result = sdl2.SDL_UpdateNVTexture( result = sdl2.SDL_UpdateNVTexture(
self._texture, None, self._texture, None,
y_arr, frame.pitch, y_ptr, frame.pitch,
uv_arr, frame.uv_pitch, uv_ptr, frame.uv_pitch,
) )
except Exception: except Exception:
log.error( log.error(
@@ -458,25 +466,25 @@ class GStreamerBackend(PlayerBackend):
if fmt_str == "NV12": if fmt_str == "NV12":
# NV12: Y plane (stride[0]) followed immediately by interleaved UV plane (stride[1]). # NV12: Y plane (stride[0]) followed immediately by interleaved UV plane (stride[1]).
y_size = int(info.stride[0]) * height # Store the WHOLE raw buffer in pixels without slicing — slicing bytes
uv_size = int(info.stride[1]) * (height // 2) # creates two extra copies (2 MB + 1 MB) that we can avoid. render()
raw = buffer.extract_dup(0, buffer.get_size()) # uses a single from_buffer_copy of the full buffer and ctypes.byref to
pixels = raw[:y_size] # address the UV plane at the y_size byte offset.
uv_pixels = raw[y_size:y_size + uv_size] pitch = int(info.stride[0])
pitch = int(info.stride[0])
uv_pitch = int(info.stride[1]) uv_pitch = int(info.stride[1])
y_size = pitch * height
raw = buffer.extract_dup(0, buffer.get_size())
if self._frame_count == 0: if self._frame_count == 0:
buf_total = buffer.get_size()
log.info( log.info(
"First NV12 frame: %dx%d y_pitch=%d uv_pitch=%d " "First NV12 frame: %dx%d y_pitch=%d uv_pitch=%d "
"y_size=%d uv_size=%d buf_total=%d", "y_size=%d buf_total=%d",
width, height, pitch, uv_pitch, y_size, uv_size, buf_total, width, height, pitch, uv_pitch, y_size, len(raw),
) )
frame = _Frame( frame = _Frame(
width=width, height=height, width=width, height=height,
pitch=pitch, pixels=pixels, pitch=pitch, pixels=raw,
pixel_format="NV12", pixel_format="NV12",
uv_pixels=uv_pixels, uv_pitch=uv_pitch, y_size=y_size, uv_pitch=uv_pitch,
) )
else: else:
pitch = int(info.stride[0]) if info.stride else width * 4 pitch = int(info.stride[0]) if info.stride else width * 4
+11 -6
View File
@@ -11,7 +11,11 @@ mppvideodec auto-selected) and reports:
• A/V sync drift (video PTS vs pipeline clock position) • A/V sync drift (video PTS vs pipeline clock position)
• from_buffer_copy() time per frame (CPU copy cost) • 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/miniconda3/envs/r36s-dlna-browser/bin/python \\
/home/ark/R36SHack/tests/benchmark_nv12_decode.py [URL] /home/ark/R36SHack/tests/benchmark_nv12_decode.py [URL]
@@ -186,12 +190,13 @@ def _on_sample(sink) -> Gst.FlowReturn:
if fmt_str == "NV12": if fmt_str == "NV12":
y_size = int(info.stride[0]) * int(info.height) y_size = int(info.stride[0]) * int(info.height)
uv_size = int(info.stride[1]) * (int(info.height) // 2) # Simulate the single from_buffer_copy the app now does:
# Simulate named c_ubyte array creation (the actual upload path) # ONE copy of the full Y+UV buffer, then ctypes.byref for the UV offset.
t1 = time.monotonic() t1 = time.monotonic()
y_arr = (ctypes.c_ubyte * len(raw[:y_size])).from_buffer_copy(raw[:y_size]) arr = (ctypes.c_ubyte * len(raw)).from_buffer_copy(raw)
uv_arr = (ctypes.c_ubyte * len(raw[y_size:y_size+uv_size])).from_buffer_copy(raw[y_size:y_size+uv_size]) y_ptr = ctypes.cast(arr, ctypes.POINTER(ctypes.c_ubyte))
copy_us = (time.monotonic() - t1) * 1e6 # override with NV12-specific copy uv_ptr = ctypes.cast(ctypes.byref(arr, y_size), ctypes.POINTER(ctypes.c_ubyte))
copy_us = (time.monotonic() - t1) * 1e6
with stats.lock: with stats.lock:
stats.total_frames += 1 stats.total_frames += 1