Skip to content

Commit 31bef33

Browse files
Add app-performance.md
1 parent b6ffcb7 commit 31bef33

2 files changed

Lines changed: 51 additions & 0 deletions

File tree

‎docs/apps/app-performance.md‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# App Performance
2+
3+
How to make MicroPythonOS apps hit smooth framerates on resource-constrained hardware. The rules below come from a real optimization pass on QuasiBird (Fri3d badge, ST7789 over SPI): **~17 → ~24.5 fps**, measured in-game.
4+
5+
## 1. Measure first, guess never
6+
7+
- Read the `sysmon:` line over serial with the FPS overlay **off**: `sysmon: 25 FPS (refr_cnt: 8 | ...)`, `refr ... (render ... | flush ...)`. The `render Xms | flush Yms` split tells you whether you're CPU-bound or bus-bound. Optimize the bigger one.
8+
- `FPS` counts refresh *cycles* per ~300 ms window — including cycles where nothing was dirty. It is not "frames your game drew".
9+
- FPS is hard-capped at ~30 by `LV_DEF_REFR_PERIOD=33ms`. A 16 ms game timer can't beat it; invalidates just coalesce until the next refresh tick.
10+
- `change_task_handler(period_ms)` sets the outer pump that evaluates LVGL timers, not the refresh rate. Smaller values reduce latency but don't raise the ceiling.
11+
- In-game FPS overlays perturb what they measure: every `set_text()` is a `malloc` + invalidate + re-blend, plus serial logging inside the measurement window. Trust the serial `sysmon` line over the on-screen number.
12+
13+
## 2. Know the display path
14+
15+
- ESP32-S3 SPI divides 80 MHz by an integer, so 60 MHz silently rounds down to 40 — 80 MHz is the only step up from 40. It only speeds up the *flush* portion of the frame; if your frame is 50 ms render + 8 ms flush, doubling the bus saves ~4 ms, not half.
16+
- Display `freq` is per-device on a shared SPI bus. An SD card (20 MHz) or radio (16 MHz) on the same bus keeps its own clock; each transfer just waits its turn.
17+
- Flushing is DMA-async with double buffering — LVGL renders the next chunk while the previous one transfers. The 33 ms refresh cap usually binds long before the bus does.
18+
- `rgb565_byte_swap=True` runs an in-place CPU swap loop over every flushed chunk before DMA. Required for correct colors on most panels — just know it's part of `flush` time, not free.
19+
20+
## 3. No floats in hot loops
21+
22+
MicroPython floats are heap objects. Float physics at 60 Hz easily means ~20 short-lived allocations per frame — constant GC pressure that shows up as *jitter* (drifting fps), not just a lower average.
23+
24+
Use integer fixed-point instead: positions in milli-pixels, velocities in milli-pixels/s, `delta_ms` kept as `int`. LVGL takes ints anyway, so convert with `//` at the display call.
25+
26+
Two traps:
27+
28+
- **Don't truncate per frame.** `SPEED * dt // 1000` in whole pixels freezes slow movers (30 px/s × 16 ms = 0.48 px → 0). Accumulate in milli-pixels so sub-pixel motion is preserved.
29+
- **Bound your counters.** An ever-growing scroll accumulator eventually outgrows MicroPython small ints and becomes a heap-allocated bigint on every op. Wrap with modulo (e.g. one screen width).
30+
31+
Verify with a float-vs-fixed simulation before flashing: identical pixel outputs (within ±1 px at wrap edges) and identical game-logic timing.
32+
33+
## 4. Redraw hygiene
34+
35+
Every invalidate costs render + flush. LVGL redraws only dirty areas, so shrink what you dirty:
36+
37+
- `label.set_text()` **always** mallocs and invalidates, even for an identical string. Only call it when the text actually changed. Same for any label you refresh "just in case" every frame.
38+
- `image.set_offset_x()` invalidates the whole object unconditionally. Guard it behind a "did the pixel change?" check.
39+
- `set_x()` / `set_y()` / `set_pos()` are no-ops when the integer pixel didn't change — call them freely.
40+
- A permanent `set_rotation()` on an image means *every frame* any overlapping dirty rect takes the slow per-pixel transform path, plus a larger invalidated area. Ship a pre-rotated asset instead (verify pixel-identity once with PIL).
41+
- `TILE` mode issues one draw call **per tile**. A 240 px strip from a 20 px tile = 12 draw calls per frame. Pre-compose the strip offline (tiny PNG) and scroll two leapfrogging images via `set_x` — 1 draw call each, identical pixels. Keep `TILE` as fallback if you target multiple screen widths.
42+
- Translucent (`bg_opa` < 255) + rounded + bordered panels force read-modify-write blending, corner masking, and repainting of everything beneath them. Opaque rectangles take the fast fill path — a visual tradeoff, budget it deliberately.
43+
- Images with transparency force the background underneath to repaint (overdraw). Opaque sprites compose cheaper.
44+
- Slow movers (parallax clouds at ~0.5 px/frame) can push to LVGL at half rate while physics keeps integrating every frame — visually identical, half the invalidates.
45+
46+
## 5. Verify like-for-like
47+
48+
- Simulate old-vs-new logic in CPython where possible (trajectories, scroll phases, wrap behavior) before spending flash cycles.
49+
- `ruff` and `mpy-cross` must run on the **real** file path: apps symlinked from external repos are not covered by the main repo's `make lint` / `make syntax-tests`.
50+
- Bump the app version with every user-visible change so testers can tell builds apart.

‎mkdocs.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ nav:
4949
- App Store: apps/appstore.md
5050
- Creating Apps: apps/creating-apps.md
5151
- App Lifecycle: apps/app-lifecycle.md
52+
- App Performance: apps/app-performance.md
5253
- Bundling Apps: apps/bundling-apps.md
5354
- Native C/C++ Apps: apps/native-apps.md
5455
- BadgeHub Apps: apps/badgehub.md

0 commit comments

Comments
 (0)