Skip to content

perf(display): dirty tracking in update_display + plugin FPS declaration - #406

Merged
ChuckBuilds merged 3 commits into
mainfrom
perf/dirty-tracking
Jul 13, 2026
Merged

perf(display): dirty tracking in update_display + plugin FPS declaration#406
ChuckBuilds merged 3 commits into
mainfrom
perf/dirty-tracking

Conversation

@ChuckBuilds

@ChuckBuilds ChuckBuilds commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Summary

PR 4 of the performance series. update_display pushed every frame to the panel unconditionally — static content re-pushed every second, high-FPS loops re-pushed 125×/sec between actual scroll steps, and static-image was hardcoded into the 125fps loop even for non-animated PNGs.

  • Digest skip: identical (frame, brightness) → no SetImage/SwapOnVSync. Hardware-safe: the driver refreshes the panel from its own thread; swaps only change content.
  • Invalidation, covered twice each: brightness is inside the digest AND set_brightness resets it (dim schedules can't be skipped); clear() resets it (writes to the matrix directly). Audit confirmed every panel-write call site lives in display_manager — nothing can bypass the digest.
  • Kill switch: display.dirty_tracking: false restores byte-exact old behavior.
  • FPS gate: plugins can declare needs_high_fps; static-image without it keeps its historical forced high-FPS (GIF back-compat). Monorepo follow-up will have static-image declare based on whether the loaded asset is animated.

Verification & honest numbers

  • 7 tests against the real DisplayManager on RGBMatrixEmulator (skip/push/clear/brightness/snapshot/kill-switch) + 202-test display/controller/vegas suites.
  • Devpi (192×48), pinned static content, 5×40s samples: min 70.0% vs pre-PR 69.8% — CPU-neutral on this workload, because the rgbmatrix driver's refresh thread owns the floor and a 1fps static mode had little to save. The measurable win arrives with the static-image follow-up (125 pushes/sec → ~0 for still images). Sample variance (70→90%) was traced to synchronous plugin-update storms — the exact problem PR 5 addresses.
  • One thing not verifiable remotely: eyeballing the physical panel dim on a schedule change (logic is emulator-tested; brightness-in-digest + explicit reset make a miss structurally impossible, but a 10s hardware spot check is welcome).

🤖 Generated with Claude Code

https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam

Summary by CodeRabbit

  • New Features

    • Added dirty tracking to skip redundant hardware frame pushes when both the image content and brightness are unchanged.
    • Added configuration to disable dirty tracking and return to always-refresh behavior.
    • Improved high-refresh-rate handling by honoring plugins’ explicit high-FPS needs (with backward-compatible fallback).
    • Preserved preview/snapshot output even when hardware updates are skipped.
  • Bug Fixes

    • Brightness changes and display clears now reliably force the next hardware refresh.
  • Tests

    • Added emulator-backed tests covering dirty tracking, brightness handling, clear behavior, and snapshot writing.

update_display now skips SetImage+SwapOnVSync when the frame is
byte-identical to the last pushed one (adler32 digest) AND brightness
is unchanged — brightness is part of the digest, and set_brightness
additionally resets it, so a dim-schedule change can never be skipped.
clear() resets the digest (it writes to the matrix directly). Skipping
a swap is hardware-safe: the panel refreshes the current frame from the
driver's own thread; swaps only change content.

Kill switch: display.dirty_tracking: false restores always-push.

display_controller's high-FPS decision gains a precedence step: a
plugin exposing needs_high_fps is honored first (so static-image can
declare False for still PNGs and stop burning a 125fps loop on them);
static-image without the attribute keeps its historical forced
high-FPS (GIF back-compat); scrolling logic is otherwise unchanged.

Verified with 7 tests against the real DisplayManager on
RGBMatrixEmulator (identical-frame skip, pixel-change push, clear and
brightness invalidation, snapshot-through-skip, kill switch) plus the
202-test display/controller/vegas suites, and a clean devpi deploy.
Audit: every SetImage/SwapOnVSync/Clear/brightness call site is inside
display_manager — no external writer can bypass the digest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@ChuckBuilds, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 51 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dc848499-5481-491a-898a-da47b5d9d63e

📥 Commits

Reviewing files that changed from the base of the PR and between 16a79cb and 0495476.

📒 Files selected for processing (3)
  • src/display_controller.py
  • src/display_manager.py
  • test/test_display_dirty_tracking.py
📝 Walkthrough

Walkthrough

The PR adds configurable framebuffer dirty tracking to skip unchanged hardware pushes while preserving snapshots, and updates per-mode rendering to honor plugins’ needs_high_fps declarations before legacy fallbacks.

Changes

Display performance controls

Layer / File(s) Summary
Dirty framebuffer tracking
src/display_manager.py, test/test_display_dirty_tracking.py
DisplayManager computes canvas-and-brightness digests, serializes updates, skips unchanged swaps, preserves snapshots, invalidates state on brightness changes and clears, and tests the configuration kill switch.

High-FPS mode selection

Layer / File(s) Summary
Plugin high-FPS decision order
src/display_controller.py
Per-mode rendering checks needs_high_fps before the existing static-image and scrolling-based fallbacks.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DisplayManager
  participant RGBMatrixEmulator
  participant SnapshotFile
  DisplayManager->>DisplayManager: Compute canvas and brightness digest
  DisplayManager->>SnapshotFile: Write snapshot when due
  DisplayManager->>RGBMatrixEmulator: SwapOnVSync when the digest changed
  DisplayManager->>DisplayManager: Cache the pushed digest
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main changes: dirty tracking in update_display and plugin-declared high-FPS behavior.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/dirty-tracking

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codacy-production

codacy-production Bot commented Jul 12, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 5 complexity · 0 duplication

Metric Results
Complexity 5
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/display_controller.py`:
- Around line 2087-2088: Update the FPS debug log in the controller’s FPS-check
flow to use structured context, add the stable “[DisplayController]” source
prefix, and include the plugin identifier alongside active_mode and
needs_high_fps. Reuse the existing plugin identity available in that flow rather
than introducing a new identifier.

In `@src/display_manager.py`:
- Around line 548-560: In the dirty-tracking digest logic, replace the broad
Exception handler around self.matrix.brightness with the specific exception type
used by the established get_brightness() and set_brightness() patterns, while
preserving the None fallback when the brightness property cannot be read.
- Around line 548-560: Serialize the entire update_display() operation with the
manager’s existing synchronization primitive, including the digest check, canvas
swap, and push/update work, so concurrent callers cannot race or both pass the
_last_pushed_digest check. Ensure every caller path into update_display() uses
the same lock and preserve the existing early-return behavior for unchanged
frames.

In `@test/test_display_dirty_tracking.py`:
- Around line 25-39: Reset DisplayManager singleton state during teardown for
both the dm fixture and test_config_flag_wires_through. Add cleanup that sets
DisplayManager._instance to None and DisplayManager._initialized to False after
each use, ensuring no configured instance leaks into later test modules.
- Around line 96-102: Update test_snapshot_still_written_on_skip to perform an
initial update followed by a second update that meets the skip conditions, then
assert the snapshot exists after the skipped push. Preserve the existing
snapshot path setup and drawing behavior while ensuring the test explicitly
exercises the skipped hardware-update path in update_display.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0d2e2ea0-21a1-4eed-8799-03520f733c94

📥 Commits

Reviewing files that changed from the base of the PR and between 6edd80d and fa30876.

📒 Files selected for processing (3)
  • src/display_controller.py
  • src/display_manager.py
  • test/test_display_dirty_tracking.py

Comment thread src/display_controller.py Outdated
Comment thread src/display_manager.py Outdated
Comment thread test/test_display_dirty_tracking.py
Comment thread test/test_display_dirty_tracking.py
…log fixes

CodeRabbit review on #406, verified against current code:

- update_display() can genuinely be called from background threads (some
  sports base classes call it directly from inside update() for an
  immediate "live" refresh), not just the render loop — confirmed via the
  existing follower-mode gating wrapper in display_controller.py, which
  exists specifically because "background plugin threads" can reach it.
  Without a lock, two callers could both pass the digest check before
  either writes _last_pushed_digest back, causing a redundant push, or
  interleave the offscreen/current canvas swap. Added self._update_lock
  (RLock, in case of re-entrant callers) around the full method body so
  every call site is automatically covered — no caller changes needed.
  (No prior lock existed to reuse on DisplayManager; this adds one.)
- Narrowed the brightness-read exception handler to AttributeError,
  matching the established pattern in get_brightness()/set_brightness()
  — a getattr() with a default already swallows AttributeError, so the
  only case this guards is the property getter itself raising, and the
  established pattern treats that as an expected, specific failure mode
  rather than something to blanket-catch.
- FPS-check debug log now includes the plugin_id already in scope
  (previously only active_mode) and a "[DisplayController]" prefix for
  grep-ability, matching the sibling log two lines below it.
- test_display_dirty_tracking.py: dm fixture and test_config_flag_wires_through
  now reset the DisplayManager singleton on teardown, matching the pattern
  test_display_manager.py already uses elsewhere in the same file family.
- test_snapshot_still_written_on_skip previously only exercised the
  non-skip (push) path despite its name; now performs a second update that
  meets the skip conditions (identical frame) and asserts the snapshot is
  still written even though the panel push itself is skipped.

All 7 dirty-tracking tests pass, plus the full display_manager/
display_controller/vegas suite (140 passed). Full repo suite has only the
5 known pre-existing failures (double-sided config x2, state_reconciliation
x2, and test_circuit_breaker's conftest.py mock signature drift — the
latter fixed in #400, which this branch's base predates).
@ChuckBuilds

Copy link
Copy Markdown
Owner Author

Addressed the CodeRabbit findings, verified against current code:

Fixed:

  • Concurrency race in update_display() — confirmed real: display_controller.py already has a follower-mode gating wrapper around update_display specifically because "background plugin threads" can call it directly (some sports base classes call self.display_manager.update_display() from inside update() for an immediate live-score refresh, not just from the render loop). Without a lock, two callers could both pass the digest check before either writes _last_pushed_digest back — a redundant push, or an interleaved offscreen/current canvas swap. One correction to the finding: there was no existing lock on DisplayManager to reuse — added self._update_lock (an RLock, in case of re-entrant callers) around the whole method body, so every call site is covered automatically with no caller changes needed.
  • Broad exception on brightness read — narrowed to AttributeError, matching the established pattern in get_brightness()/set_brightness() elsewhere in this file. getattr(..., default) already swallows AttributeError itself, so this only guards the property getter raising something else — and the codebase's own convention treats that as a specific, expected failure mode, not a catch-all.
  • FPS debug log missing plugin identity — now includes plugin_id (already in scope, reused rather than introducing a new identifier) plus a [DisplayController] prefix, matching its sibling log two lines below.
  • Test singleton leakdm fixture and test_config_flag_wires_through now reset DisplayManager._instance/_initialized on teardown, matching the pattern already used elsewhere in test_display_manager.py.
  • test_snapshot_still_written_on_skip didn't test the skip path — it only ever called update_display() once (the push path). Now does a second call that meets the skip conditions and asserts the snapshot is still written even though the panel push itself is skipped via a _SwapSpy count of 0.

All 7 dirty-tracking tests pass, plus the full display_manager/display_controller/vegas suite (140 passed). Full repo suite shows only the 5 known pre-existing failures unrelated to this change.

@ChuckBuilds
ChuckBuilds merged commit 9837315 into main Jul 13, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant