Skip to content

Performance

github-actions[bot] edited this page Sep 9, 2026 · 24 revisions

Performance

English · Deutsch

The rendering budget for anyone adding or changing a screen.

The constraint

The frame rate on this panel is limited by PSRAM (pseudo-static random-access memory) bandwidth, not by the CPU. The LCD (liquid-crystal display) peripheral scans one framebuffer out of PSRAM continuously at about 30 MB/s, and the framebuffers sit behind a write-back, write-allocate data cache (64 KB, 8-way, 64-byte lines). Every pixel the CPU writes costs a 64-byte line fill and a 64-byte write-back unless the line is already resident. The cost of a frame is therefore a count of cache-line fills, which can be measured exactly on the host.

tools/frame_cost.py builds the real screens for the host and runs them under cachegrind with the ESP32-S3's cache geometry. It reports the difference between rendering one frame and eleven, so process start-up and the first paint of each buffer cancel out.

$ python3 tools/frame_cost.py
panel 39.0 Hz, ~39 MB/s effective -> 976 KiB of traffic per panel frame

mode       lines/frame     traffic   est. ms  est. fps
-------------------------------------------------------
frame            8,856     1107 KiB     29.1      19.5
frame-idle        1,274      159 KiB      4.2      39.0
held             4,777      597 KiB     15.7      39.0
sim              9,817     1227 KiB     32.2      19.5
throttle        10,598     1325 KiB     34.8      19.5
chrome          33,106     4138 KiB    108.7       7.8
overview           953      119 KiB      3.1      39.0
servo           15,759     1970 KiB     51.7      13.0
servo-grip        3,026      378 KiB      9.9      39.0
analyser           871      109 KiB      2.9      39.0
logs               937      117 KiB      3.1      39.0
settings           862      108 KiB      2.8      39.0
battery            871      109 KiB      2.9      39.0
balance            865      108 KiB      2.8      39.0
programmer          895      112 KiB      2.9      39.0
balance-sim        2,395      299 KiB      7.9      39.0
settings-sim        2,407      301 KiB      7.9      39.0
battery-sim        2,401      300 KiB      7.9      39.0
analyser-chrome       39,234     4904 KiB    128.8       6.5
logs-chrome       16,061     2008 KiB     52.7      13.0
settings-chrome       23,591     2949 KiB     77.4       9.8
battery-chrome       36,987     4623 KiB    121.4       7.8
balance-chrome       40,685     5086 KiB    133.5       6.5
programmer-chrome       28,480     3560 KiB     93.5       9.8
picker             902      113 KiB      3.0      39.0
picker-chrome       15,883     1985 KiB     52.1      13.0
clear           12,006     1501 KiB     39.4      19.5
vlines           8,160     1020 KiB     26.8      19.5
hlines               0        0 KiB      0.0      39.0
Mode What it measures
frame the motor bench on a frame where a telemetry sample lands
frame-idle the motor bench on a frame between samples, nothing touched
held the motor bench between runs: live readouts over a plot that is holding the last one
sim as frame, with the SIMULATION watermark
throttle the motor bench with a finger on the throttle, the drag case
chrome the motor bench with nothing cached, repainted in full
overview the menu, chrome cached
servo the servo screen with the arm redrawn
servo-grip the servo screen with only the grip repainted
analyser, logs, settings, battery, balance, programmer, picker one steady frame of that screen, chrome cached
<screen>-sim the same screen with the SIMULATION watermark
<screen>-chrome the same screen invalidated on every frame
clear a full-screen clear
vlines seventeen full-height vertical lines
hlines the same pixel count as horizontal lines

Absolute counts shift by a few fills between machines, because argv and the environment share the cache with the framebuffer. frame_cost.py --check-doc therefore checks the table to a tolerance of 1%.

Rules

Draw row-major. Seventeen full-height vertical lines cost 8,160 fills; the same pixel count as horizontal lines costs zero, because each line is resident from the previous pixel. The shell is laid out in horizontal bands, and a vertical rule is a deliberate expense.

Cache the chrome. Repainting everything costs about thirty times the steady state. Every screen keeps a per-framebuffer bitmask of what it has already painted; that is what the buffer_index argument of render() is for. The panel alternates between two buffers, so a screen that invalidates only the buffer being drawn leaves the other one a frame behind, which reads as flicker.

Cache a stencil that does not move. SIMULATION is drawn on every frame whenever the bench numbers carry LINK_BN_SIMULATED, which includes a coprocessor answering with simulated numbers. Rotated text scans its rotated bounding box, which corner to corner is the whole canvas, and rotates and divides per pixel to write the 3,439 pixels the mark covers, 0.9% of the canvas. ui_watermark records those points once and writes them thereafter: 144,721 instructions per frame in place of 8,412,078.

A count of fills is not a count of cycles. The table above measures cache-line fills, and the mark costs only 1,586 of them: it is arithmetic per pixel, not traffic. It was 58 times the cost the table implied, and the table could not show it. Where a mode's measured frame time exceeds what its fills predict, count instructions before trusting the estimate.

Give a control that moves every frame its own counter. A drag delivers a touch per frame. The motor screen's buttons and its throttle shared one revision, so dragging repainted every control beside it. The throttle now carries its own, and repaints the readout's box and the slider's painted region: the throttle mode in the table above measures it against a sample frame. The flip quantises to whole panel frames, so a drag either lands inside two of them or waits for a third: 19.5 fps or 13.0. Measured on hardware at 50.5 ms, the old drag took the third.

Clear what a widget paints, not what it occupies. The slider's thumb and its shadow stand proud of the track by 5 px above and 7 px below, and the hero numerals are 32 px tall with 3 px of slant on a 30 px row, so a clear sized to the track or to the row leaves a thumb at every position the finger passed and the numerals' feet behind. ui_slider_painted_rect() reports the region rather than leaving a caller to derive it.

Paint only on frames that have something to paint. Samples arrive at 20 Hz and the panel refreshes at 39 Hz, so about every other frame has nothing new. The bench screen keeps the plot's push count and a control revision, each per framebuffer, and repaints the plot, the readouts and the controls only when the corresponding counter has moved. each_framebuffer_is_updated_independently in test_motor pins the per-buffer counters.

Ceilings

The panel moves 976 KiB per frame at 39 Hz. A render costing twice that lands at 19.5 fps, which is one frame per 20 Hz telemetry sample; drawing faster would repaint identical pixels, drawing slower would drop samples. CI (continuous integration) holds each mode to a ceiling:

Modes Ceiling (fills) Catches
frame, sim 15,600 a bench frame that exceeds one telemetry sample
overview 2,000 a chrome-cached screen that has started repainting
servo 17,000 the arm and grip drawing growing
servo-grip 4,000 a breath repainting the whole card
the seven per-screen modes 1,200 a screen that has started repainting
the three -sim modes 2,800 the watermark growing past a full canvas
the seven -chrome modes 45,000 a full repaint growing

If a future pane needs more room, the remaining levers in order of bluntness are the plot's height, its width, and clipping the simulation watermark to the region that was repainted.

The ESP32-S3 log line DRAW … WAIT …, printed every 300 frames, is the on-hardware check: DRAW is the paint time, WAIT is how long the flip blocked. A healthy frame is mostly WAIT.

Clone this wiki locally