perf(renderer): preallocate skeleton cpu buffer

Replace per-frame floats list + struct.pack in _update_skeleton
with a preallocated numpy.float32 staging buffer (_skel_cpu_buf),
eliminating the dynamic allocation on every draw call (~60/s).
This commit is contained in:
L'électron rare
2026-05-13 11:58:32 +02:00
parent 5eefe7baa3
commit 20acbeb704
3 changed files with 57 additions and 6 deletions
+19 -6
View File
@@ -19,6 +19,8 @@ import struct
import time
from pathlib import Path
import numpy as np
import objc
from Cocoa import NSObject, NSColor, NSMakeRect
from Metal import (
@@ -142,6 +144,7 @@ class MetalRenderer(NSObject):
self._mesh_buf = self._device.newBufferWithLength_options_(
MESH_MAX_TRIS * 3 * MESH_VERT_FLOATS * 4, MTLResourceStorageModeShared)
self._mp_bones = _mediapipe_bones() # None si pas dispo
self._init_skel_cpu_buffer()
self._build_pipelines()
self._last_lightning_emit = 0.0
return self
@@ -226,6 +229,16 @@ class MetalRenderer(NSObject):
raise RuntimeError(f"mesh pipeline failed: {err}")
self._mesh_pipe = p
# ---- CPU staging buffers --------------------------------------
def _init_skel_cpu_buffer(self) -> None:
"""Preallocate the CPU staging buffer for skeleton segments.
SKEL_MAX_SEGS * 10 floats : each segment = 2 verts × 5 floats
(x, y, z, conf, pid). Idempotent — no-op if already allocated.
"""
if getattr(self, "_skel_cpu_buf", None) is None:
self._skel_cpu_buf = np.zeros(SKEL_MAX_SEGS * 10, dtype=np.float32)
# ---- Uniforms helpers ------------------------------------------
def _update_uniforms(self) -> int:
s = self._state
@@ -286,19 +299,19 @@ class MetalRenderer(NSObject):
if not s.pose_alive():
return 0
floats: list[float] = []
buf = self._skel_cpu_buf
segs = 0
def push(A, B, conf, pid):
"""Empile un segment (2 verts). Chaque vert = (x, y, z, conf, pid)."""
"""Empile un segment (2 verts) dans le buffer CPU prealloque."""
nonlocal segs
if segs >= SKEL_MAX_SEGS:
return False
ax = A.x * 2.0 - 1.0; ay = 1.0 - A.y * 2.0
bx = B.x * 2.0 - 1.0; by = 1.0 - B.y * 2.0
az = float(A.z); bz = float(B.z)
floats.extend([ax, ay, az, conf, float(pid),
bx, by, bz, conf, float(pid)])
i = segs * 10
buf[i+0] = ax; buf[i+1] = ay; buf[i+2] = float(A.z); buf[i+3] = conf; buf[i+4] = float(pid)
buf[i+5] = bx; buf[i+6] = by; buf[i+7] = float(B.z); buf[i+8] = conf; buf[i+9] = float(pid)
segs += 1
return True
@@ -359,7 +372,7 @@ class MetalRenderer(NSObject):
if segs == 0:
return 0
data = struct.pack(f"{len(floats)}f", *floats)
data = self._skel_cpu_buf[: segs * 10].tobytes()
mv = self._skel_buf.contents().as_buffer(len(data))
mv[:] = data
return segs
+9
View File
@@ -0,0 +1,9 @@
"""pytest configuration: ensure package root is on sys.path for test imports."""
import sys
from pathlib import Path
# Add the parent of data_only_viz/ to sys.path so that
# "from data_only_viz.xxx import ..." works at module-level in test files.
_parent = str(Path(__file__).resolve().parent.parent.parent)
if _parent not in sys.path:
sys.path.insert(0, _parent)
@@ -0,0 +1,29 @@
# data_only_viz/tests/test_renderer_allocations.py
"""Renderer must reuse a preallocated CPU staging buffer for the skeleton."""
import numpy as np
from data_only_viz.renderer import MetalRenderer, SKEL_MAX_SEGS
from data_only_viz.state import State
def test_skeleton_cpu_buffer_is_preallocated_and_reused():
r = MetalRenderer.__new__(MetalRenderer) # bypass __init__ side effects (Metal)
r._init_skel_cpu_buffer()
buf = r._skel_cpu_buf
assert isinstance(buf, np.ndarray)
assert buf.dtype == np.float32
assert buf.size == SKEL_MAX_SEGS * 10
# A second call must return the same object (no realloc):
r._init_skel_cpu_buffer()
assert r._skel_cpu_buf is buf
def test_update_skeleton_fills_existing_buffer():
r = MetalRenderer.__new__(MetalRenderer)
r._init_skel_cpu_buffer()
r._mp_bones = None # force COCO fallback path
s = State()
# No persons → returns 0
n = r._update_skeleton(s)
assert n == 0