Performance - subtilitas/rcbench GitHub Wiki
English · Deutsch
The rendering budget for anyone adding or changing a screen.
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,840 1105 KiB 29.0 19.5
frame-idle 1,261 158 KiB 4.1 39.0
held 4,756 594 KiB 15.6 39.0
sim 9,799 1225 KiB 32.2 19.5
throttle 10,761 1345 KiB 35.3 19.5
chrome 33,199 4150 KiB 109.0 7.8
overview 947 118 KiB 3.1 39.0
servo 15,555 1944 KiB 51.1 19.5
servo-grip 3,061 383 KiB 10.0 39.0
servo-current 4,060 508 KiB 13.3 39.0
supply 8,565 1071 KiB 28.1 19.5
supply-chrome 31,560 3945 KiB 103.6 7.8
analyser 876 110 KiB 2.9 39.0
logs 909 114 KiB 3.0 39.0
settings 852 106 KiB 2.8 39.0
settings-list 852 106 KiB 2.8 39.0
battery 863 108 KiB 2.8 39.0
balance 869 109 KiB 2.9 39.0
programmer 879 110 KiB 2.9 39.0
balance-sim 2,384 298 KiB 7.8 39.0
settings-sim 2,395 299 KiB 7.9 39.0
battery-sim 2,384 298 KiB 7.8 39.0
analyser-chrome 39,240 4905 KiB 128.8 6.5
logs-chrome 16,179 2022 KiB 53.1 13.0
settings-chrome 18,710 2339 KiB 61.4 13.0
settings-list-chrome 23,868 2984 KiB 78.3 9.8
battery-chrome 36,994 4624 KiB 121.4 7.8
balance-chrome 40,811 5101 KiB 133.9 6.5
programmer-chrome 29,257 3657 KiB 96.0 9.8
picker 874 109 KiB 2.9 39.0
picker-chrome 22,084 2760 KiB 72.5 13.0
wifi 1,007 126 KiB 3.3 39.0
wifi-chrome 13,870 1734 KiB 45.5 19.5
wifi-keys 22,168 2771 KiB 72.8 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 |
servo-current |
as servo-grip, with the INA3221 as the servo rail's meter and a window in every frame whose current differs in the digits shown: the grip, the CURRENT row's value, the line and the plot repainted, each clipped to itself |
supply |
the supply screen with its output on, a sample landing every frame |
analyser, logs, settings, battery, balance, programmer, picker, wifi
|
one steady frame of that screen, chrome cached; settings is SETUP's tiles |
settings-list |
SETUP with ESC / BENCH's list open, chrome cached |
wifi-keys |
FILES & UPDATE with the Wi-Fi keyboard open, a key typed every frame |
<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%.
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.
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, supply
|
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 |
servo-current |
4,500 | a changed digit of the current repainting the whole card |
| the eight per-screen modes | 1,200 | a screen that has started repainting |
settings-list |
1,200 | a SETUP list that has started repainting |
the three -sim modes |
2,800 | the watermark growing past a full canvas |
the ten -chrome modes |
45,000 | a full repaint growing |
wifi-keys |
24,000 | a key of the Wi-Fi keyboard repainting more than the screen |
tools/check_docs.py holds this table to the --max-lines arguments in
.github/workflows/ci.yml: a ceiling that differs, a mode CI holds with no
row here, and a row CI does not run each fail.
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.
Every panel task runs on a fixed stack, and a call chain that runs past the
end of one restarts the panel. tools/stack_check.py reads each task's
deepest call chain out of the panel ELF (Executable and Linkable Format)
file: the frame of every function is the entry a1, N it opens with, and the
depth is the largest sum of frames along any chain from the task's entry
point. A jump out of a function, the form a tail call takes, counts as a
call. The tasks are every xTaskCreatePinnedToCore() and xTaskCreate()
in firmware/panel, and the main task. CI runs it after both panel builds
and fails when a task's depth exceeds its stack less 1024 bytes.
| Task | Entry | Stack (bytes) | Deepest chain (bytes) | Spare below the margin (bytes) |
|---|---|---|---|---|
main |
main_task, which calls app_main and runs the UI |
8,192 | 4,960 | 2,208 |
control |
control_task |
6,144 | 4,288 | 832 |
runlog |
log_task |
4,352 | 2,992 | 336 |
knob |
knob_task, the rotary knob's reader |
3,072 | 1,360 | 688 |
touch |
touch_task, the GT911 reader (components/gt911) |
4,096 | 1,808 | 1,264 |
wifi_ctl |
worker, which starts and stops the radio and the page's server (components/wifi_access) |
5,120 | 3,360 | 736 |
mdns |
mdns_task, the mDNS (multicast domain name system) responder while the panel is joined to a home network |
4,096 | 1,808 | 1,264 |
wifi_get |
fetch_task, one release check or download from GitHub (components/wifi_access) |
8,192 | 2,400 | 4,768 |
The page's HTTP (hypertext transfer protocol) server runs on a task of
8,192 bytes, and the Wi-Fi driver, the TCP/IP (transmission control
protocol, internet protocol) stack and the event loop on tasks of their own.
ESP-IDF creates them, so they are not in the table: their depth is not
measured. wifi_get's depth leaves out the TLS (transport layer security)
handshake and every read under it: esp_http_client reaches its transport
through a function pointer. Its stack is the page server's, which never runs
beside it; the handshake's depth is not measured.
Measured on ESP-IDF v5.4 at -O2. Of the margin, 528 bytes are spent outside the frames: 320 for the FPU (floating-point unit) and vector-unit state saved at the top of every stack, 192 for the frame an interrupt pushes, and 16 below the deepest frame. The other 496 bytes cover what the tool cannot see, and each depth above is a lower bound for that reason:
- calls through a function pointer, except the router's calls into a screen,
which the tool reads out of every screen's table, and the link port's
callbacks, the drawing store's flash callbacks and the start check's after
an update, which it follows by name
(
PORT_CALLS,FLASH_CALLS,BOOT_CALLS): 224 such calls are reachable frommain_task, most of them in ESP-IDF's storage and display drivers; - calls into the ESP32-S3's ROM (read-only memory), whose frames are not in the ELF;
- recursion, which the tool counts once.
CI runs the tool with --check-doc on the ESP-IDF v5.4 build, which fails
when the table above or the count of calls through a pointer differs from
that build.
tools/stack_check.py --iomcu reads the RP2350 image the same way. Core 0
runs main() on the pico-sdk's main stack: 4096 bytes, __StackBottom to
__StackTop in the ELF, set by PICO_STACK_SIZE=0x1000 in
firmware/iomcu/CMakeLists.txt. Core 1 runs core1_main() on the 4096-byte array
its launch passes. Neither stack has a guard. An interrupt runs on the stack
of the core it interrupts, so each core is charged its deepest chain and one
interrupt: a 108-byte exception frame and the deepest handler the image
installs. CI runs the tool after the coprocessor build and fails when a
core's chain, one interrupt and a margin of 256 bytes exceed its stack.
| Core | Entry | Stack (bytes) | Deepest chain (bytes) | One interrupt (bytes) | Spare below the margin (bytes) |
|---|---|---|---|---|---|
| core 0 | main |
4,096 | 1,716 | 528 | 1,596 |
| core 1 | core1_main |
4,096 | 640 | 528 | 2,672 |
Measured on the image CI builds, pico-sdk 2.3.0 with arm-none-eabi-gcc
13.2.1. CI runs the tool with --check-doc, which fails when core 0's row
differs from that build. Core 1's row is not held: its chain moves with the
compiler (ARM GNU 14.2 gives 644 bytes), and CI builds the image with the
runner's packaged one. The frames named below are those of a build with
ARM GNU 14.2, which gives 1,708 bytes for core 0.
Core 0's deepest chain is a request that binds an output and fails in the
pico-sdk: main (472 bytes), can_service (336), link_dev_dispatch,
slots_write, slots_take, ports_apply, slots_rerate,
outputs_hw_apply_only, out_dshot_bind, the
pico-sdk's claim of a PIO (programmable input/output) state machine, and
panic() with what it calls to print, 228 bytes. The deepest handler is
the USB stack's worker, low_priority_worker_irq, at 420 bytes; its chain
ends in panic() as well.
A write command to the programming port for KST and Chaservo servos reaches 1,304 bytes: main
(464), can_service (344), link_dev_dispatch (32), kst_write (24),
kst_port_write (88), kst_session_edit (40) and the start of a planner
job, 312 bytes from kst_plan_edit_begin down: kst_plan_edit_begin
(40), the limit check kst_limits_edit (248), load (16) and
kst_field_get (8). A pass that plans reaches 1,016 bytes: main,
kst_port_step (48), step_programming (40), kst_session_step (256) and one slice of the
planner, 208 bytes from kst_plan_step down. The planner does not
recurse. The planner's job, the plan and the staged image are held in the
session's and the port's state, not on the stack.
The port plans a write in slices. The write command checks the request and
starts the planner's job, 64 us of planner work. Each pass of core 0's
main loop then runs 1 slice in kst_session_step(), 100 to 109 units of
work: 892 us at most, 816 us the longest found. The planning ends after
216,000 units, at most 2,161 passes, with a plan, with no plan, or with
the result "too complex" when the units are spent first. The costliest
request found takes 120,136 units, at most 1,202 passes and 397 to 647 ms
of planner time. All times are for 150 MHz, computed in an emulator from
the instructions executed with ARM GNU 14.2 at -O3, and not measured on
hardware. The time of a pass with the link, the heartbeat and the outputs
in it is not measured. The heartbeat gap that switches every output off is
150 ms.
Each depth is a lower bound:
- calls through a register are not followed, except a page's read and write
handler, which the tool reads out of the link's page table: 91 such calls
are reachable from
main; - the 1,088-byte frame of newlib's
two_way_long_needle()is left out.strstr()calls it for a needle of 255 characters or more, and the two needles in the firmware are 3 and 5 characters. The tool fails on astrstr()call undershared/orfirmware/iomcu/srcwhose needle is not a string literal under 255 characters; - 17 hand-written arithmetic routines of the pico-sdk carry no size in the ELF, and their frames are not read;
- one interrupt is counted, not a second one on top of it;
- recursion is counted once.
Core 0's stack is the whole of the RAM region SCRATCH_Y, 0x20081000 to
0x20082000; the image places nothing else in it, and the link fails when
something is. Below it, in SCRATCH_X, lies the 4096-byte array the
pico-sdk reserves as its own core 1 stack, which this image does not run
on. The same definition sizes that array.
-v lists all of them, and each task's deepest chain.
The UI keeps its frames small where it is cheap. A page's drawer is a
separate function, so only the page on screen holds its buffers: on
PROGRAMMER, render()'s frame is 32 bytes and the largest page's 464. A
line copied for display is copied without snprintf(), which reaches
newlib's float conversion: 1,952 bytes deep in the call graph.
The radio needs internal RAM (random-access memory) that PSRAM cannot
stand in for. esp_wifi_init() takes the Wi-Fi driver's static RX
(receive) and TX (transmit) buffers: 16 of each, about 1,600 bytes each,
51,200 bytes of DMA-capable (direct memory access) internal RAM.
CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP makes the TX buffers static. When
they do not fit, the radio does not start, and FILES & UPDATE shows "Wi-Fi
did not start.".
tools/ram_budget.py reads the internal heap a panel build leaves and
subtracts what the panel allocates at run time, then what each Wi-Fi mode
allocates on top. CI runs it after both panel builds and fails when the
heap left with the largest mode up is under 32,768 bytes: the size of the
pool ESP-IDF keeps for internal and DMA allocations at run time
(CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL). The radio's sockets, the
hardware AES (Advanced Encryption Standard) and SHA (Secure Hash Algorithm)
engines' buffers and a card transfer take from it while the radio runs.
| Item | What it is | Bytes |
|---|---|---|
static |
internal RAM the image's code and data hold, up to _heap_start
|
126,832 |
heap |
internal heap at start: _heap_start to 0x3FCE9710, and the ROM's (read-only memory's) region to 0x3FCF0000, which the heap takes over after start-up |
299,152 |
start-up |
the task stacks and control blocks of ESP-IDF and of the panel, every queue and semaphore, the display's two bounce buffers, and an allowance of 12,288 bytes for the drivers | 104,776 |
before |
heap left with the panel up and the radio off | 194,376 |
AP |
ACCESS POINT: the radio, the page's server and its task | 98,724 |
JOIN |
joined to a home network: AP's and the mDNS responder | 103,160 |
SCAN |
the network search: the radio alone | 86,096 |
FETCH |
a release check or download: the radio, the wifi_get task and an allowance of 4,096 bytes for TLS |
98,724 |
left |
heap left with the largest mode up | 91,216 |
Read from the ESP-IDF v5.4 build. CI runs the tool with --check-doc on
that build, which fails when the table differs from it. -v lists every
allocation and where its size comes from.
Each figure is an estimate. A task's stack, a queue's length and the Wi-Fi
buffer count are read from the build and the source; the sizes of a task's
control block, a queue's header and a queue's item are sizeof() values
read with GDB from the v5.4 build. Three allowances are not read from
anything: 12,288 bytes for the drivers' start-up allocations, 16,384 bytes
for the Wi-Fi driver's blocks beyond its buffers and its task, and 4,096
bytes for TLS. The tool leaves out RTC (real-time clock) fast memory, about
8 KB of heap that is not DMA-capable. The free heap on a panel is not
measured. The panel prints it at start-up:
I (…) rcbench: internal RAM: <n> B free, largest block <n> B; PSRAM: <n> B free
A radio start that fails logs the step, the error's name and the internal heap at that moment:
E (…) wifi: esp_wifi_init: ESP_ERR_NO_MEM; internal RAM <n> B free, largest block <n> B
The steps are esp_netif_init, esp_event_loop_create_default, the two
event handlers, the network interface, esp_wifi_init,
esp_wifi_set_mode, esp_wifi_set_config, esp_wifi_start,
httpd_start, and the creation of the wifi_ctl, wifi_get and mdns
tasks. A network interface ESP-IDF cannot allocate stops the panel with an
assertion inside ESP-IDF instead, and the panel restarts.
What lives in PSRAM instead: the zero-initialised data (.bss) of 15 objects,
161,340 bytes, named in firmware/panel/main/psram_bss.lf, and, through
CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY, ESP-IDF's own choice of the
.bss of lwIP (lightweight IP stack), net80211, pp and wpa_supplicant,
13,742 bytes.
| Object | Component | .bss (bytes) |
|---|---|---|
log_viewer_screen |
ui | 35,328 |
servo_screen |
ui | 28,509 |
ui_watermark |
ui | 20,504 |
supply_screen |
ui | 20,412 |
motor_screen |
ui | 19,968 |
programmer_screen |
ui | 6,396 |
kst_card |
bench | 6,208 |
log_csv |
logfile | 4,905 |
kst_panel |
ui | 4,645 |
esc_registry |
esc | 4,228 |
analyser_screen |
ui | 4,036 |
wa_zip |
webaccess | 2,433 |
wa_page |
webaccess | 1,408 |
wifi_screen |
ui | 1,320 |
ui_band |
ui | 1,040 |
An object moves only when its state
- is read and written by no interrupt handler: the panel's IRAM
(instruction RAM) handlers are
display.c's andtouch.c's, and their state stays internal; - is read and written by no DMA: the card's SPI (Serial Peripheral Interface) driver copies a buffer that is not DMA-capable through one that is;
- holds no atomic, spinlock, mutex or queue: the ESP32-S3's compare-and-swap instruction works on internal RAM only.
A flash write closes the cache, and PSRAM with it. For its length the writing task runs from IRAM and the other core waits in IRAM, so no code that reads these objects runs.
The screens' state is read through the same 64 KB data cache as the
framebuffers. tools/frame_cost.py models the frames it renders through
that cache with every read and write, these objects' included. The frame rate on a
panel with them in PSRAM is not measured.