mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
perf(scroll): pace frames to the panel, not to a fixed sleep
Scrolling ran at 44-46 fps on a 2x128x64 chain and 14-17% of frames took 41-53ms, which reads as judder. Four independent causes, each measured on the hardware; details and the diagnostic recipe are in docs/SCROLL_PERFORMANCE.md. The high-FPS loop slept a flat 8ms after every render. display() has already blocked on the panel's vsync by then, so that sleep was added to a wait that had happened: ~4ms of render plus 8ms put each iteration at ~12ms against a 10ms refresh grid, so every swap missed a refresh and the loop settled at 50fps while asking for 125 -- with no headroom, so a further 14% of frames slipped again. It now sleeps only the remainder, with a 1ms floor so plugin threads still get the GIL. ScrollHelper stepped position on a wall clock at 1/scroll_delay steps per second. Plugins set scroll_delay to the frame period, so that comparison sat exactly on its own threshold: a frame arriving a hair early moved zero pixels and rendered an identical frame, dirty-tracking skipped the swap, it returned in ~2ms, and the beat repeated. No scroll_delay value tunes that out -- a shorter delay trades stalled frames for periodic double-steps. Both modes now accumulate elapsed time at the same configured speed, so position stays proportional to real time. Sub-pixel blending goes back to off by default. It renders a half-step by mixing two adjacent columns, which on a coarse panel showing pixel-font text alternates crisp and smeared frames and reads as shimmer -- visibly worse than integer stepping on the hardware. Vegas mode still opts in. disk_cache uses orjson when importable, falling back to the stdlib. Encoding a ~1MB record drops from 14.8ms to 5.4ms end-to-end, and that work holds the GIL while a marquee is on screen. display_manager also checksummed the whole framebuffer twice per frame (dirty tracking, then the preview snapshot); the snapshot now takes the checksum the caller already computed. New src/common/scroll_config.py resolves scroll settings in one place. Five ticker plugins each hand-rolled this and disagreed: odds-ticker ranked the deprecated scroll_pixels_per_second above the documented scroll_speed/delay pair, and because that key carries a schema default the documented settings were dead for every user (ChuckBuilds/ledmatrix-plugins#408), while ledmatrix-leaderboard read the same key only as a fallback. The resolver also warns when a speed will not advance a whole number of pixels per refresh, which is the property that actually determines whether a scroll looks smooth. scripts/build_rgbmatrix_nogil.sh rebuilds the rgbmatrix binding so it releases the GIL. Upstream declares SwapOnVSync without nogil, unlike SetPixel/Clear/Fill beside it, so the render thread held the GIL for the whole vsync wait and starved background threads into long uninterruptible bursts. The script patches, builds and self-verifies into a scratch tree; --install backs up the original and rolls back if the service does not come back healthy. Measured after: 100 fps locked, no stalls observed, render thread down from 51% to 19% of one core. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Rebuild the rgbmatrix Python binding so it releases the GIL.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# ---------------
|
||||
# The upstream binding declares FrameCanvas::SwapOnVSync WITHOUT `nogil`
|
||||
# (cppinc.pxd), unlike SetPixel/Clear/Fill on the lines just above it.
|
||||
# SwapOnVSync blocks until the panel's next vertical sync -- up to a full
|
||||
# refresh period on every frame -- so the render thread was holding the GIL
|
||||
# for most of every frame. Background threads (API fetches, JSON parsing,
|
||||
# image decode) were starved into long uninterruptible bursts, which in turn
|
||||
# made the render loop miss refreshes.
|
||||
#
|
||||
# Measured on a Pi 4 driving a 2x128x64 chain at limit_refresh_rate_hz=100:
|
||||
#
|
||||
# before ~44 fps average, 14-17% of frames 41-53ms
|
||||
# after 100 fps locked, no stalls observed
|
||||
#
|
||||
# This script also releases the GIL across the per-pixel blit
|
||||
# (SetPixelsPillow) and walks the Pillow buffer row-major instead of
|
||||
# column-major so each row is contiguous.
|
||||
#
|
||||
# SAFETY
|
||||
# ------
|
||||
# Builds into a scratch directory; touches the installed module only in the
|
||||
# --install step, and backs up the original first. Roll back at any time with:
|
||||
#
|
||||
# sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
|
||||
#
|
||||
# USAGE
|
||||
# bash scripts/build_rgbmatrix_nogil.sh # build only
|
||||
# sudo bash scripts/build_rgbmatrix_nogil.sh --install
|
||||
# sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
SRC_TREE="${RGB_SRC_TREE:-$HOME/LEDMatrix/rpi-rgb-led-matrix-master}"
|
||||
BUILD_DIR="${RGB_BUILD_DIR:-$HOME/rgbmatrix-nogil-build}"
|
||||
VENV="${RGB_CYTHON_VENV:-$HOME/.cache/ledmatrix-cython}"
|
||||
BACKUP="${RGB_BACKUP:-$HOME/rgbmatrix-core.so.ORIGINAL}"
|
||||
|
||||
die() { echo "FATAL: $*" >&2; exit 1; }
|
||||
|
||||
py_site() {
|
||||
python3 -c 'import rgbmatrix, os; print(os.path.dirname(rgbmatrix.__file__))' 2>/dev/null
|
||||
}
|
||||
|
||||
abi_so() {
|
||||
ls "$BUILD_DIR"/bindings/python/rgbmatrix/core.cpython-*.so 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
do_rollback() {
|
||||
local dst; dst="$(py_site)" || die "rgbmatrix not importable"
|
||||
[ -n "$dst" ] || die "could not locate the installed rgbmatrix package"
|
||||
[ -f "$BACKUP" ] || die "no backup at $BACKUP"
|
||||
systemctl stop ledmatrix 2>/dev/null
|
||||
cp -a "$BACKUP" "$dst/core.so" || die "restore failed"
|
||||
find "$dst" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null
|
||||
systemctl start ledmatrix 2>/dev/null
|
||||
echo "rolled back to the original core.so"
|
||||
exit 0
|
||||
}
|
||||
|
||||
do_install() {
|
||||
local so dst
|
||||
so="$(abi_so)"; [ -n "$so" ] || die "no built module found - run the build first"
|
||||
dst="$(py_site)"; [ -n "$dst" ] || die "could not locate the installed rgbmatrix package"
|
||||
|
||||
if [ ! -f "$BACKUP" ]; then
|
||||
cp -a "$dst/core.so" "$BACKUP" || die "could not back up the original"
|
||||
echo "backed up original core.so -> $BACKUP"
|
||||
else
|
||||
echo "backup already present at $BACKUP (keeping the true original)"
|
||||
fi
|
||||
|
||||
systemctl stop ledmatrix 2>/dev/null
|
||||
cp "$so" "$dst/core.so" || die "install failed"
|
||||
find "$dst" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null
|
||||
systemctl start ledmatrix 2>/dev/null
|
||||
|
||||
echo "waiting 25s for the display to come back..."
|
||||
sleep 25
|
||||
local healthy=1
|
||||
systemctl is-active --quiet ledmatrix || healthy=0
|
||||
if journalctl -u ledmatrix --since "40 sec ago" --no-pager \
|
||||
| grep -qiE "Traceback|ImportError|Segmentation fault|undefined symbol"; then
|
||||
healthy=0
|
||||
fi
|
||||
if [ "$healthy" = "1" ]; then
|
||||
echo "SUCCESS - running on the rebuilt binding"
|
||||
else
|
||||
echo "UNHEALTHY - rolling back"
|
||||
cp -a "$BACKUP" "$dst/core.so"
|
||||
systemctl restart ledmatrix
|
||||
journalctl -u ledmatrix --since "90 sec ago" --no-pager | tail -25
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
--rollback) do_rollback ;;
|
||||
--install) do_install ;;
|
||||
"" ) ;;
|
||||
*) die "unknown option: $1" ;;
|
||||
esac
|
||||
|
||||
# ---------------------------------------------------------------- build ----
|
||||
[ -d "$SRC_TREE" ] || die "matrix source tree not found at $SRC_TREE (set RGB_SRC_TREE)"
|
||||
command -v g++ >/dev/null || die "g++ not installed (apt install build-essential)"
|
||||
|
||||
echo "==> staging a scratch copy at $BUILD_DIR"
|
||||
rm -rf "$BUILD_DIR"
|
||||
cp -r "$SRC_TREE" "$BUILD_DIR" || die "copy failed"
|
||||
|
||||
echo "==> patching the bindings to release the GIL"
|
||||
python3 - "$BUILD_DIR" <<'PYEOF' || die "patch failed"
|
||||
import io, sys
|
||||
base = sys.argv[1] + "/bindings/python/rgbmatrix/"
|
||||
|
||||
p = base + "cppinc.pxd"
|
||||
s = io.open(p, encoding="utf-8").read()
|
||||
old = " FrameCanvas *SwapOnVSync(FrameCanvas*, uint8_t)\n"
|
||||
new = " FrameCanvas *SwapOnVSync(FrameCanvas*, uint8_t) nogil\n"
|
||||
if old in s:
|
||||
io.open(p, "w", encoding="utf-8", newline="\n").write(s.replace(old, new, 1))
|
||||
print(" cppinc.pxd: SwapOnVSync declared nogil")
|
||||
elif new in s:
|
||||
print(" cppinc.pxd: already nogil")
|
||||
else:
|
||||
sys.exit("could not find the SwapOnVSync declaration")
|
||||
|
||||
p = base + "core.pyx"
|
||||
s = io.open(p, encoding="utf-8").read()
|
||||
|
||||
old = """ def SwapOnVSync(self, FrameCanvas newFrame, uint8_t framerate_fraction = 1):
|
||||
return __createFrameCanvas(self.__matrix.SwapOnVSync(newFrame.__canvas, framerate_fraction))
|
||||
"""
|
||||
new = """ def SwapOnVSync(self, FrameCanvas newFrame, uint8_t framerate_fraction = 1):
|
||||
# Blocks until the panel's next vertical sync. Holding the GIL across
|
||||
# that wait starves every other Python thread for most of each frame.
|
||||
cdef cppinc.RGBMatrix* matrix = self.__matrix
|
||||
cdef cppinc.FrameCanvas* frame = newFrame.__canvas
|
||||
cdef uint8_t fraction = framerate_fraction
|
||||
cdef cppinc.FrameCanvas* swapped
|
||||
with nogil:
|
||||
swapped = matrix.SwapOnVSync(frame, fraction)
|
||||
return __createFrameCanvas(swapped)
|
||||
"""
|
||||
if old in s:
|
||||
s = s.replace(old, new, 1)
|
||||
print(" core.pyx: SwapOnVSync releases the GIL")
|
||||
elif "with nogil:\n swapped = matrix.SwapOnVSync" in s:
|
||||
print(" core.pyx: SwapOnVSync already patched")
|
||||
else:
|
||||
sys.exit("could not find the SwapOnVSync body")
|
||||
|
||||
old = """ buffer = get_pillow_buffer(image_capsule)
|
||||
|
||||
for col in range(max(0, -xstart), min(width, frame_width - xstart)):
|
||||
for row in range(max(0, -ystart), min(height, frame_height - ystart)):
|
||||
pixel = buffer[row][col]
|
||||
r = (pixel ) & 0xFF
|
||||
g = (pixel >> 8) & 0xFF
|
||||
b = (pixel >> 16) & 0xFF
|
||||
my_canvas.SetPixel(xstart+col, ystart+row, r, g, b)
|
||||
"""
|
||||
new = """ buffer = get_pillow_buffer(image_capsule)
|
||||
|
||||
# Bounds hoisted so the blit needs no Python state and can run without
|
||||
# the GIL: it touches only a C buffer and a C++ canvas. Row-major order
|
||||
# walks each row contiguously; col-outer re-strided the whole buffer.
|
||||
cdef int col_start = max(0, -xstart)
|
||||
cdef int col_end = min(width, frame_width - xstart)
|
||||
cdef int row_start = max(0, -ystart)
|
||||
cdef int row_end = min(height, frame_height - ystart)
|
||||
|
||||
with nogil:
|
||||
for row in range(row_start, row_end):
|
||||
for col in range(col_start, col_end):
|
||||
pixel = buffer[row][col]
|
||||
r = (pixel ) & 0xFF
|
||||
g = (pixel >> 8) & 0xFF
|
||||
b = (pixel >> 16) & 0xFF
|
||||
my_canvas.SetPixel(xstart+col, ystart+row, r, g, b)
|
||||
"""
|
||||
if old in s:
|
||||
s = s.replace(old, new, 1)
|
||||
print(" core.pyx: pixel blit releases the GIL, row-major")
|
||||
elif "with nogil:\n for row in range(row_start, row_end):" in s:
|
||||
print(" core.pyx: blit already patched")
|
||||
else:
|
||||
sys.exit("could not find the SetPixelsPillow loop")
|
||||
|
||||
io.open(p, "w", encoding="utf-8", newline="\n").write(s)
|
||||
PYEOF
|
||||
|
||||
echo "==> building librgbmatrix.a (this takes a few minutes)"
|
||||
nice -n 10 make -C "$BUILD_DIR/lib" -j2 >/dev/null 2>&1 \
|
||||
|| die "library build failed - rerun 'make -C $BUILD_DIR/lib' to see why"
|
||||
[ -f "$BUILD_DIR/lib/librgbmatrix.a" ] || die "librgbmatrix.a was not produced"
|
||||
|
||||
echo "==> preparing Cython"
|
||||
[ -d "$VENV" ] || python3 -m venv --system-site-packages "$VENV" || die "venv failed"
|
||||
"$VENV/bin/pip" install --quiet cython || die "cython install failed"
|
||||
|
||||
cat > "$BUILD_DIR/bindings/python/setup.py" <<'EOF'
|
||||
from setuptools import setup, Extension
|
||||
from Cython.Build import cythonize
|
||||
|
||||
core = Extension(
|
||||
"rgbmatrix.core",
|
||||
sources=["rgbmatrix/core.pyx", "rgbmatrix/shims/pillow.c"],
|
||||
include_dirs=["../../include", "rgbmatrix/shims"],
|
||||
extra_objects=["../../lib/librgbmatrix.a"],
|
||||
language="c++",
|
||||
extra_compile_args=["-O3", "-Wall", "-fno-exceptions", "-std=c++11"],
|
||||
extra_link_args=["-lrt", "-lm", "-lpthread"],
|
||||
)
|
||||
|
||||
setup(name="rgbmatrix",
|
||||
ext_modules=cythonize([core], language_level="3str",
|
||||
compiler_directives={"binding": False}))
|
||||
EOF
|
||||
|
||||
echo "==> compiling the extension"
|
||||
( cd "$BUILD_DIR/bindings/python" && "$VENV/bin/python" setup.py build_ext --inplace ) \
|
||||
>/dev/null 2>&1 || die "extension build failed"
|
||||
|
||||
SO="$(abi_so)"; [ -n "$SO" ] || die "no .so produced"
|
||||
|
||||
# Verify the GIL really is released before anyone installs this.
|
||||
PAIRS=$(grep -c "PyEval_SaveThread\|Py_UNBLOCK_THREADS" "$BUILD_DIR/bindings/python/rgbmatrix/core.cpp")
|
||||
[ "$PAIRS" -ge 2 ] || die "generated C++ has only $PAIRS GIL releases, expected >= 2"
|
||||
|
||||
echo
|
||||
echo "BUILT: $SO"
|
||||
echo " ($PAIRS GIL-release sites in the generated C++)"
|
||||
echo
|
||||
echo "Install with: sudo bash $0 --install"
|
||||
echo "Roll back with: sudo bash $0 --rollback"
|
||||
Reference in New Issue
Block a user