From a93a7acd49ad2b90009098f41608bd9bd75dea6b Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:41:17 -0600 Subject: [PATCH 1/3] MicroPython: LVGL's memory pools on ESP32 ESP32 builds were meant to keep LVGL's allocations in pools held in GC blocks, but the pool code compiled in only when MICROPY_MALLOC_USES_ALLOCATED_SIZE was 1, which the esp32 port leaves at 0, so every LVGL allocation still walked the GC allocation table (#10). ESP32 now uses the same TLSF pools as other ports, on by default, and LVMP_POOL_TLSF=0 turns them off. The switch keys on CONFIG_IDF_TARGET, which mpconfigport.h brings in from sdkconfig.h, not on ESP_PLATFORM: the esp32 port compiles user C modules a second time without ESP_PLATFORM, and that copy is the one linked. --- .../micropython/src/lv_mem_core_micropython.c | 49 +++++++++---------- ports/micropython/src/lvmp_pool.h | 18 ++++--- ports/micropython/src/lvmp_tlsf.c | 6 +-- 3 files changed, 38 insertions(+), 35 deletions(-) diff --git a/ports/micropython/src/lv_mem_core_micropython.c b/ports/micropython/src/lv_mem_core_micropython.c index 52adca9..81fa3f1 100644 --- a/ports/micropython/src/lv_mem_core_micropython.c +++ b/ports/micropython/src/lv_mem_core_micropython.c @@ -13,15 +13,16 @@ #include #include -// ESP32: LVGL's allocations come from TLSF pools (ESP-IDF's multi_heap) kept -// inside GC-heap blocks, not from gc_alloc() one by one. +// LVGL's allocations come from TLSF pools kept inside GC-heap blocks, not +// from gc_alloc() one by one. On by default on ESP32; other ports set +// LVMP_POOL_TLSF=1 (lvmp_pool.h). // // gc_alloc() finds room by walking the allocation table from the first free // block, and LVGL's thousands of small, long-lived blocks leave the table // full of one-block holes. On an ESP32-S3 with its heap in PSRAM a 64-byte // allocation took ~90 us and a 2 KB one ~1.3 ms, and creating one LVGL // widget ~1-2.5 ms: a 30-row list took 2 s to build (Waveshare LCD-7, -// 2026-09-25). multi_heap allocates in O(1). +// 2026-09-25). TLSF allocates in O(1). // // The pools are ordinary GC blocks, reachable through a root pointer, so the // collector still scans every byte of LVGL's memory: a Python wrapper or @@ -29,16 +30,14 @@ // gc_alloc(). Allocations of LVMP_POOL_BIG bytes or more, and anything that // does not fit, still go to gc_alloc(). // -// Other ports: the same pools on LVGL's own TLSF (lv_tlsf.c, built for this in -// lvmp_tlsf.c) when the board sets LVMP_POOL_TLSF=1. STM32H743 with a 28 MB -// GC heap in SDRAM: a full-screen refresh of 48 buttons took 54 ms with a -// fresh heap and 575 ms once 2 MB of bytearrays sat behind a free block, every -// LVGL allocation walking the table past them (mpy-lvgl-boards, 2026-11-02). +// The allocator is LVGL's own TLSF (lv_tlsf.c, built for this in lvmp_tlsf.c). +// STM32H743 with a 28 MB GC heap in SDRAM: a full-screen refresh of 48 buttons +// took 54 ms with a fresh heap and 575 ms once 2 MB of bytearrays sat behind a +// free block, every LVGL allocation walking the table past them +// (mpy-lvgl-boards, 2026-11-02). #include "lvmp_pool.h" -#if LVMP_POOL && defined(ESP_PLATFORM) -#include "multi_heap.h" -#elif LVMP_POOL +#if LVMP_POOL // lv_tlsf.h declares these only with LV_STDLIB_BUILTIN. typedef void * lv_tlsf_t; lv_tlsf_t lv_tlsf_create_with_pool(void * mem, size_t bytes); @@ -46,12 +45,12 @@ void * lv_tlsf_malloc(lv_tlsf_t tlsf, size_t bytes); void * lv_tlsf_realloc(lv_tlsf_t tlsf, void * ptr, size_t size); size_t lv_tlsf_free(lv_tlsf_t tlsf, const void * ptr); size_t lv_tlsf_block_size(void * ptr); -typedef lv_tlsf_t multi_heap_handle_t; -#define multi_heap_register(mem, bytes) lv_tlsf_create_with_pool((mem), (bytes)) -#define multi_heap_malloc lv_tlsf_malloc -#define multi_heap_realloc lv_tlsf_realloc -#define multi_heap_free(h, p) ((void)lv_tlsf_free((h), (p))) -#define multi_heap_get_allocated_size(h, p) lv_tlsf_block_size(p) +typedef lv_tlsf_t lvmp_heap_t; +#define lvmp_heap_register(mem, bytes) lv_tlsf_create_with_pool((mem), (bytes)) +#define lvmp_heap_malloc lv_tlsf_malloc +#define lvmp_heap_realloc lv_tlsf_realloc +#define lvmp_heap_free(h, p) ((void)lv_tlsf_free((h), (p))) +#define lvmp_heap_size(h, p) lv_tlsf_block_size(p) #endif #if LVMP_POOL @@ -60,7 +59,7 @@ typedef lv_tlsf_t multi_heap_handle_t; // them) by the root pointer lvmp_pool_mem, registered in lvgl_micropython_build.c: // make builds do not scan this file (SRC_USERMOD_LIB_C) for root pointers. -static multi_heap_handle_t lvmp_heaps[LVMP_POOL_MAX]; +static lvmp_heap_t lvmp_heaps[LVMP_POOL_MAX]; static uint8_t *lvmp_start[LVMP_POOL_MAX]; static uint8_t *lvmp_end[LVMP_POOL_MAX]; static int lvmp_n; @@ -95,7 +94,7 @@ static bool lvmp_pool_add(void) if (mem == NULL) { return false; } - multi_heap_handle_t h = multi_heap_register(mem, LVMP_POOL_BYTES); + lvmp_heap_t h = lvmp_heap_register(mem, LVMP_POOL_BYTES); if (h == NULL) { gc_free(mem); return false; @@ -114,13 +113,13 @@ static void *lvmp_pool_malloc(size_t size) return NULL; } for (int i = 0; i < lvmp_n; i++) { - void *p = multi_heap_malloc(lvmp_heaps[i], size); + void *p = lvmp_heap_malloc(lvmp_heaps[i], size); if (p != NULL) { return p; } } if (lvmp_pool_add()) { - return multi_heap_malloc(lvmp_heaps[lvmp_n - 1], size); + return lvmp_heap_malloc(lvmp_heaps[lvmp_n - 1], size); } return NULL; } @@ -208,16 +207,16 @@ void * lv_realloc_core(void * p, size_t new_size) #if LVMP_POOL int pool = p != NULL ? lvmp_pool_of(p) : -1; if (pool >= 0) { - void *q = multi_heap_realloc(lvmp_heaps[pool], p, new_size); + void *q = lvmp_heap_realloc(lvmp_heaps[pool], p, new_size); if (q != NULL || new_size == 0) { return q; } // Did not fit in its pool: move it (another pool, or the GC heap). - size_t old_size = multi_heap_get_allocated_size(lvmp_heaps[pool], p); + size_t old_size = lvmp_heap_size(lvmp_heaps[pool], p); q = lv_malloc_core(new_size); if (q != NULL) { memcpy(q, p, old_size < new_size ? old_size : new_size); - multi_heap_free(lvmp_heaps[pool], p); + lvmp_heap_free(lvmp_heaps[pool], p); } return q; } @@ -240,7 +239,7 @@ void lv_free_core(void * p) #if LVMP_POOL int pool = p != NULL ? lvmp_pool_of(p) : -1; if (pool >= 0) { - multi_heap_free(lvmp_heaps[pool], p); + lvmp_heap_free(lvmp_heaps[pool], p); return; } #endif diff --git a/ports/micropython/src/lvmp_pool.h b/ports/micropython/src/lvmp_pool.h index bd088d5..fd31300 100644 --- a/ports/micropython/src/lvmp_pool.h +++ b/ports/micropython/src/lvmp_pool.h @@ -11,15 +11,19 @@ #include "py/mpconfig.h" -// ESP32: ESP-IDF's multi_heap. Other ports: LVGL's TLSF, when the board sets -// LVMP_POOL_TLSF=1. -#if defined(ESP_PLATFORM) && MICROPY_MALLOC_USES_ALLOCATED_SIZE -#define LVMP_POOL (1) -#elif defined(LVMP_POOL_TLSF) && LVMP_POOL_TLSF -#define LVMP_POOL (1) +// On by default on ESP32, off elsewhere; a build sets LVMP_POOL_TLSF to 1 or 0 +// to choose. ESP32 is told apart by CONFIG_IDF_TARGET, which the port's +// mpconfigport.h brings in from sdkconfig.h, not by ESP_PLATFORM: the esp32 +// port compiles every user C module a second time, for the executable, without +// ESP_PLATFORM, and that second copy is the one linked. +#ifndef LVMP_POOL_TLSF +#if defined(CONFIG_IDF_TARGET) +#define LVMP_POOL_TLSF (1) #else -#define LVMP_POOL (0) +#define LVMP_POOL_TLSF (0) #endif +#endif +#define LVMP_POOL (LVMP_POOL_TLSF) #define LVMP_POOL_MAX (8) #define LVMP_POOL_BYTES (256 * 1024) diff --git a/ports/micropython/src/lvmp_tlsf.c b/ports/micropython/src/lvmp_tlsf.c index 20da16f..ad908f7 100644 --- a/ports/micropython/src/lvmp_tlsf.c +++ b/ports/micropython/src/lvmp_tlsf.c @@ -5,14 +5,14 @@ /** * @file lvmp_tlsf.c * - * LVGL's TLSF allocator for the LVMP pools of lv_mem_core_micropython.c on - * ports without ESP-IDF's multi_heap (LVMP_POOL_TLSF=1). LVGL compiles + * LVGL's TLSF allocator for the LVMP pools of lv_mem_core_micropython.c + * (LVMP_POOL_TLSF, see lvmp_pool.h). LVGL compiles * lv_tlsf.c only with LV_STDLIB_BUILTIN, so it is built here, in a translation * unit of its own, with that setting: nothing else in LVGL changes. */ #include "lvgl/src/lv_conf_internal.h" -#if LV_USE_STDLIB_MALLOC == LV_STDLIB_MICROPYTHON_OVERRIDE && defined(LVMP_POOL_TLSF) && LVMP_POOL_TLSF #include "lvmp_pool.h" +#if LV_USE_STDLIB_MALLOC == LV_STDLIB_MICROPYTHON_OVERRIDE && LVMP_POOL #undef LV_USE_STDLIB_MALLOC #define LV_USE_STDLIB_MALLOC LV_STDLIB_BUILTIN // lv_tlsf.c sizes its tables for LV_MEM_SIZE + LV_MEM_POOL_EXPAND_SIZE, which From bac086abbf1c553e39824d16e9622ecad949fafa Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:35:05 -0600 Subject: [PATCH 2/3] MicroPython: LVGL's first memory pool is 32 KB, and each new one doubles gc.collect() scans a pool whole, used or not. With one 256 KB pool, a collection on a fresh ESP32-P4 heap took 11.4 ms against 2.9 ms without pools, while LVGL kept about 30 KB in it. Pools now start at 32 KB and double up to 256 KB, with ten of them (about 2 MB) before allocations go back to gc_alloc(). --- ports/micropython/src/lv_mem_core_micropython.c | 10 +++++++--- ports/micropython/src/lvmp_pool.h | 7 ++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/ports/micropython/src/lv_mem_core_micropython.c b/ports/micropython/src/lv_mem_core_micropython.c index 81fa3f1..6d72fcd 100644 --- a/ports/micropython/src/lv_mem_core_micropython.c +++ b/ports/micropython/src/lv_mem_core_micropython.c @@ -90,11 +90,15 @@ static bool lvmp_pool_add(void) if (lvmp_n >= LVMP_POOL_MAX) { return false; } - void *mem = gc_alloc(LVMP_POOL_BYTES, 0); + size_t bytes = (size_t)LVMP_POOL_FIRST << lvmp_n; + if (bytes > LVMP_POOL_BYTES) { + bytes = LVMP_POOL_BYTES; + } + void *mem = gc_alloc(bytes, 0); if (mem == NULL) { return false; } - lvmp_heap_t h = lvmp_heap_register(mem, LVMP_POOL_BYTES); + lvmp_heap_t h = lvmp_heap_register(mem, bytes); if (h == NULL) { gc_free(mem); return false; @@ -102,7 +106,7 @@ static bool lvmp_pool_add(void) MP_STATE_VM(lvmp_pool_mem)[lvmp_n] = mem; lvmp_heaps[lvmp_n] = h; lvmp_start[lvmp_n] = mem; - lvmp_end[lvmp_n] = (uint8_t *)mem + LVMP_POOL_BYTES; + lvmp_end[lvmp_n] = (uint8_t *)mem + bytes; lvmp_n++; return true; } diff --git a/ports/micropython/src/lvmp_pool.h b/ports/micropython/src/lvmp_pool.h index fd31300..0f7231f 100644 --- a/ports/micropython/src/lvmp_pool.h +++ b/ports/micropython/src/lvmp_pool.h @@ -25,7 +25,12 @@ #endif #define LVMP_POOL (LVMP_POOL_TLSF) -#define LVMP_POOL_MAX (8) +// The first pool is small and each new one doubles, up to LVMP_POOL_BYTES: +// gc.collect() scans a pool whole, used or not, so a pool much larger than +// what LVGL keeps in it costs every collection. 32 + 64 + 128 + 7 x 256 KB, +// about 2 MB, before allocations go back to gc_alloc(). +#define LVMP_POOL_MAX (10) +#define LVMP_POOL_FIRST (32 * 1024) #define LVMP_POOL_BYTES (256 * 1024) #define LVMP_POOL_BIG (32 * 1024) From 34183cbde6685a6a9034c10e3392a7a0a1ccd8f5 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:46:26 -0600 Subject: [PATCH 3/3] MicroPython: LVGL's memory pools are opt-in on ESP32, as on other ports Even with a 32 KB first pool, gc.collect() on a fresh ESP32-P4 heap took 6.1 ms with the pools against 2.9 ms without, too much to impose on every ESP32 build for faster LVGL allocation on a fragmented heap. A build turns them on with LVMP_POOL_TLSF=1, set where the root-pointer scan sees it (mpconfigboard.h, or CFLAGS_EXTRA on make ports). --- .../micropython/src/lv_mem_core_micropython.c | 4 ++-- ports/micropython/src/lvmp_pool.h | 20 ++++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/ports/micropython/src/lv_mem_core_micropython.c b/ports/micropython/src/lv_mem_core_micropython.c index 6d72fcd..8138496 100644 --- a/ports/micropython/src/lv_mem_core_micropython.c +++ b/ports/micropython/src/lv_mem_core_micropython.c @@ -14,8 +14,8 @@ #include // LVGL's allocations come from TLSF pools kept inside GC-heap blocks, not -// from gc_alloc() one by one. On by default on ESP32; other ports set -// LVMP_POOL_TLSF=1 (lvmp_pool.h). +// from gc_alloc() one by one, when the build sets LVMP_POOL_TLSF to 1 +// (lvmp_pool.h). // // gc_alloc() finds room by walking the allocation table from the first free // block, and LVGL's thousands of small, long-lived blocks leave the table diff --git a/ports/micropython/src/lvmp_pool.h b/ports/micropython/src/lvmp_pool.h index 0f7231f..7b48fa1 100644 --- a/ports/micropython/src/lvmp_pool.h +++ b/ports/micropython/src/lvmp_pool.h @@ -11,18 +11,20 @@ #include "py/mpconfig.h" -// On by default on ESP32, off elsewhere; a build sets LVMP_POOL_TLSF to 1 or 0 -// to choose. ESP32 is told apart by CONFIG_IDF_TARGET, which the port's -// mpconfigport.h brings in from sdkconfig.h, not by ESP_PLATFORM: the esp32 -// port compiles every user C module a second time, for the executable, without -// ESP_PLATFORM, and that second copy is the one linked. +// Off unless a build sets LVMP_POOL_TLSF to 1, on every port. Set it where the +// root-pointer scan sees it too: in the board's mpconfigboard.h, or in +// CFLAGS_EXTRA on a make port. A definition that reaches only the compiler +// (on the esp32 port, a CMake target_compile_definitions) leaves the pools' +// root pointer unregistered and the build fails. +// +// The pools trade collection time for allocation time: gc.collect() scans +// them whole, used or not. On an ESP32-P4 with 31 MB of GC heap in PSRAM, a +// collection on a fresh heap took 6.1 ms with them against 2.9 ms without, +// while creating and deleting 50 labels on a fragmented heap took 14 ms with +// them against 43-49 ms without. #ifndef LVMP_POOL_TLSF -#if defined(CONFIG_IDF_TARGET) -#define LVMP_POOL_TLSF (1) -#else #define LVMP_POOL_TLSF (0) #endif -#endif #define LVMP_POOL (LVMP_POOL_TLSF) // The first pool is small and each new one doubles, up to LVMP_POOL_BYTES: