From 7ab8df8922aca5ccd70283da36df8779ec2db8cd Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:39:08 +0000 Subject: [PATCH] perf: cache recent decoded map tiles --- lib/tdeck_ui/UI/LXMF/DecodedTileCache.cpp | 114 ++++++++++++++++++ lib/tdeck_ui/UI/LXMF/DecodedTileCache.h | 51 ++++++++ lib/tdeck_ui/UI/LXMF/MapScreen.cpp | 25 +++- lib/tdeck_ui/UI/LXMF/MapScreen.h | 3 + .../test_map_tile_downloader_contract.py | 13 ++ tests/native/test_decoded_tile_cache.cpp | 104 ++++++++++++++++ tests/native/test_decoded_tile_cache.py | 41 +++++++ 7 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 lib/tdeck_ui/UI/LXMF/DecodedTileCache.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/DecodedTileCache.h create mode 100644 tests/native/test_decoded_tile_cache.cpp create mode 100644 tests/native/test_decoded_tile_cache.py diff --git a/lib/tdeck_ui/UI/LXMF/DecodedTileCache.cpp b/lib/tdeck_ui/UI/LXMF/DecodedTileCache.cpp new file mode 100644 index 00000000..4269ecde --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/DecodedTileCache.cpp @@ -0,0 +1,114 @@ +// Copyright (c) 2026 Pyxis contributors +// SPDX-License-Identifier: MIT + +#include "DecodedTileCache.h" + +#include + +namespace Pyxis { + +DecodedTileCache::DecodedTileCache(std::size_t pixel_count) + : pixel_count_(pixel_count), entries_{} {} + +bool DecodedTileCache::sameKey(const Hardware::TDeck::TileKey& left, + const Hardware::TDeck::TileKey& right) { + return left.zoom == right.zoom && left.x == right.x && left.y == right.y; +} + +bool DecodedTileCache::attach(std::size_t index, std::uint16_t* pixels) { + if (index >= CAPACITY || pixels == NULL) return false; + entries_[index].pixels = pixels; + entries_[index].valid = false; + entries_[index].rank = 0U; + return true; +} + +int DecodedTileCache::find(const Hardware::TDeck::TileKey& key) const { + for (std::size_t index = 0U; index < CAPACITY; ++index) { + if (entries_[index].pixels != NULL && entries_[index].valid && + sameKey(entries_[index].key, key)) { + return static_cast(index); + } + } + return -1; +} + +int DecodedTileCache::selectTarget() const { + int oldest = -1; + std::uint8_t oldest_rank = 0U; + for (std::size_t index = 0U; index < CAPACITY; ++index) { + if (entries_[index].pixels == NULL) continue; + if (!entries_[index].valid) return static_cast(index); + if (oldest < 0 || entries_[index].rank > oldest_rank) { + oldest = static_cast(index); + oldest_rank = entries_[index].rank; + } + } + return oldest; +} + +void DecodedTileCache::touch(std::size_t index) { + const std::uint8_t previous = entries_[index].valid + ? entries_[index].rank + : static_cast(CAPACITY); + for (std::size_t other = 0U; other < CAPACITY; ++other) { + if (other == index || !entries_[other].valid || + entries_[other].pixels == NULL) continue; + if (entries_[other].rank < previous) ++entries_[other].rank; + } + entries_[index].rank = 0U; +} + +bool DecodedTileCache::get(const Hardware::TDeck::TileKey& key, + std::uint16_t* output, + std::size_t pixel_count) { + if (output == NULL || pixel_count != pixel_count_) return false; + const int found = find(key); + if (found < 0) return false; + const std::size_t index = static_cast(found); + std::memcpy(output, entries_[index].pixels, + pixel_count_ * sizeof(std::uint16_t)); + touch(index); + return true; +} + +bool DecodedTileCache::put(const Hardware::TDeck::TileKey& key, + const std::uint16_t* input, + std::size_t pixel_count) { + if (input == NULL || pixel_count != pixel_count_) return false; + int target = find(key); + if (target < 0) target = selectTarget(); + if (target < 0) return false; + const std::size_t index = static_cast(target); + std::memcpy(entries_[index].pixels, input, + pixel_count_ * sizeof(std::uint16_t)); + entries_[index].key = key; + touch(index); + entries_[index].valid = true; + return true; +} + +void DecodedTileCache::clear() { + for (std::size_t index = 0U; index < CAPACITY; ++index) { + entries_[index].valid = false; + entries_[index].rank = 0U; + } +} + +std::size_t DecodedTileCache::validCount() const { + std::size_t count = 0U; + for (std::size_t index = 0U; index < CAPACITY; ++index) { + if (entries_[index].valid && entries_[index].pixels != NULL) ++count; + } + return count; +} + +std::size_t DecodedTileCache::attachedCount() const { + std::size_t count = 0U; + for (std::size_t index = 0U; index < CAPACITY; ++index) { + if (entries_[index].pixels != NULL) ++count; + } + return count; +} + +} // namespace Pyxis diff --git a/lib/tdeck_ui/UI/LXMF/DecodedTileCache.h b/lib/tdeck_ui/UI/LXMF/DecodedTileCache.h new file mode 100644 index 00000000..f86dabea --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/DecodedTileCache.h @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Pyxis contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LXMF_DECODED_TILE_CACHE_H +#define UI_LXMF_DECODED_TILE_CACHE_H + +#include +#include + +#include "Hardware/TDeck/MapTileStore.h" + +namespace Pyxis { + +/** Fixed-capacity LRU over caller-owned decoded RGB565 buffers. */ +class DecodedTileCache { +public: + static constexpr std::size_t CAPACITY = 12U; + + explicit DecodedTileCache(std::size_t pixel_count); + + bool attach(std::size_t index, std::uint16_t* pixels); + bool get(const Hardware::TDeck::TileKey& key, + std::uint16_t* output, std::size_t pixel_count); + bool put(const Hardware::TDeck::TileKey& key, + const std::uint16_t* input, std::size_t pixel_count); + void clear(); + + std::size_t validCount() const; + std::size_t attachedCount() const; + +private: + struct Entry { + Hardware::TDeck::TileKey key; + std::uint16_t* pixels; + std::uint8_t rank; + bool valid; + }; + + std::size_t pixel_count_; + Entry entries_[CAPACITY]; + + static bool sameKey(const Hardware::TDeck::TileKey& left, + const Hardware::TDeck::TileKey& right); + int find(const Hardware::TDeck::TileKey& key) const; + int selectTarget() const; + void touch(std::size_t index); +}; + +} // namespace Pyxis + +#endif diff --git a/lib/tdeck_ui/UI/LXMF/MapScreen.cpp b/lib/tdeck_ui/UI/LXMF/MapScreen.cpp index b48f2aa9..d500029a 100644 --- a/lib/tdeck_ui/UI/LXMF/MapScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/MapScreen.cpp @@ -68,7 +68,8 @@ MapScreen::MapScreen(lv_obj_t* parent) status_label_(nullptr), attribution_label_(nullptr), zoom_label_(nullptr), zoom_out_button_(nullptr), zoom_in_button_(nullptr), recenter_button_(nullptr), pan_buttons_{}, tile_images_{}, - tile_descriptors_{}, tile_pixels_{}, approximation_halos_{}, markers_{}, marker_labels_{}, + tile_descriptors_{}, tile_pixels_{}, decoded_tile_cache_(TILE_PIXEL_COUNT), + decoded_cache_pixels_{}, approximation_halos_{}, markers_{}, marker_labels_{}, presenter_(), storage_(), store_config_{STORE_ENTRY_CAPACITY, STORE_BYTE_QUOTA, MAX_COMPRESSED_TILE_BYTES}, @@ -160,6 +161,14 @@ MapScreen::MapScreen(lv_obj_t* parent) tile_images_[index] = lv_img_create(viewport_); lv_obj_add_flag(tile_images_[index], LV_OBJ_FLAG_HIDDEN); } + for (std::size_t index = 0U; index < Pyxis::DecodedTileCache::CAPACITY; ++index) { + decoded_cache_pixels_[index] = static_cast(heap_caps_malloc( + TILE_PIXEL_COUNT * sizeof(std::uint16_t), + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (decoded_cache_pixels_[index]) { + (void)decoded_tile_cache_.attach(index, decoded_cache_pixels_[index]); + } + } for (std::size_t index = 0; index < MARKER_COUNT; ++index) { approximation_halos_[index] = lv_obj_create(viewport_); @@ -232,6 +241,10 @@ MapScreen::~MapScreen() { if (tile_pixels_[index]) heap_caps_free(tile_pixels_[index]); tile_pixels_[index] = nullptr; } + for (std::size_t index = 0U; index < Pyxis::DecodedTileCache::CAPACITY; ++index) { + if (decoded_cache_pixels_[index]) heap_caps_free(decoded_cache_pixels_[index]); + decoded_cache_pixels_[index] = nullptr; + } if (compressed_staging_) heap_caps_free(compressed_staging_); compressed_staging_ = nullptr; if (state_mutex_) vSemaphoreDelete(state_mutex_); @@ -416,6 +429,13 @@ Pyxis::MapTileLoadResult MapScreen::downloadTile( Pyxis::MapTileLoadResult MapScreen::readTile( const Pyxis::MapTileRequest& request) { + if (request.slot_index < TILE_COUNT && tile_pixels_[request.slot_index] && + decoded_tile_cache_.get( + request.key, + reinterpret_cast(tile_pixels_[request.slot_index]), + TILE_PIXEL_COUNT)) { + return Pyxis::MapTileLoadResult::READY; + } if (!store_initialized_) { return Pyxis::MapTileLoadResult::STORAGE_UNAVAILABLE; } @@ -499,6 +519,9 @@ Pyxis::MapTileLoadResult MapScreen::readTile( rgb[source + 2U]); } lv_mem_free(rgb); + (void)decoded_tile_cache_.put( + request.key, reinterpret_cast(pixels), + TILE_PIXEL_COUNT); return Pyxis::MapTileLoadResult::READY; } diff --git a/lib/tdeck_ui/UI/LXMF/MapScreen.h b/lib/tdeck_ui/UI/LXMF/MapScreen.h index 8e2eb8c8..a9cb436e 100644 --- a/lib/tdeck_ui/UI/LXMF/MapScreen.h +++ b/lib/tdeck_ui/UI/LXMF/MapScreen.h @@ -5,6 +5,7 @@ #define UI_LXMF_MAP_SCREEN_H #include "MapScreenPresenter.h" +#include "DecodedTileCache.h" #ifdef ARDUINO @@ -72,6 +73,8 @@ private: lv_obj_t* tile_images_[TILE_COUNT]; lv_img_dsc_t tile_descriptors_[TILE_COUNT]; lv_color_t* tile_pixels_[TILE_COUNT]; + Pyxis::DecodedTileCache decoded_tile_cache_; + std::uint16_t* decoded_cache_pixels_[Pyxis::DecodedTileCache::CAPACITY]; lv_obj_t* approximation_halos_[MARKER_COUNT]; lv_obj_t* markers_[MARKER_COUNT]; lv_obj_t* marker_labels_[MARKER_COUNT]; diff --git a/tests/build_scripts/test_map_tile_downloader_contract.py b/tests/build_scripts/test_map_tile_downloader_contract.py index 71435d37..85e17110 100644 --- a/tests/build_scripts/test_map_tile_downloader_contract.py +++ b/tests/build_scripts/test_map_tile_downloader_contract.py @@ -86,6 +86,19 @@ def test_downloader_is_explicitly_opt_in_and_wired_only_for_visible_misses(): assert '"Offline"' not in status +def test_recent_decoded_tiles_use_a_fixed_psram_lru_before_sd_decode(): + screen = MAP_SCREEN.read_text() + header = (ROOT / "lib/tdeck_ui/UI/LXMF/MapScreen.h").read_text() + cache = (ROOT / "lib/tdeck_ui/UI/LXMF/DecodedTileCache.h").read_text() + assert "CAPACITY = 12U" in cache + assert "decoded_tile_cache_.get" in screen + assert screen.index("decoded_tile_cache_.get") < screen.index("store_.beginGet") + assert "decoded_tile_cache_.put" in screen + assert "MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT" in screen + assert "decoded_cache_pixels_[Pyxis::DecodedTileCache::CAPACITY]" in header + assert "heap_caps_free(decoded_cache_pixels_[index])" in screen + + def test_settings_save_defers_persistence_and_application_outside_lvgl(): settings = SETTINGS.read_text() capture = settings[settings.index("void SettingsScreen::save_settings()"): diff --git a/tests/native/test_decoded_tile_cache.cpp b/tests/native/test_decoded_tile_cache.cpp new file mode 100644 index 00000000..27b967ca --- /dev/null +++ b/tests/native/test_decoded_tile_cache.cpp @@ -0,0 +1,104 @@ +#include +#include +#include + +#include "UI/LXMF/DecodedTileCache.h" + +namespace { +int passed = 0; +int failures = 0; +#define CHECK(expr) do { if (expr) { ++passed; } else { ++failures; std::cerr << "FAIL line " << __LINE__ << ": " #expr << '\n'; } } while (false) + +Hardware::TDeck::TileKey key(std::uint8_t zoom, std::uint32_t x, std::uint32_t y) { + Hardware::TDeck::TileKey value{}; + value.zoom = zoom; + value.x = x; + value.y = y; + return value; +} + +void missDoesNotMutateOutput() { + Pyxis::DecodedTileCache cache(4U); + std::uint16_t storage[4] = {}; + std::uint16_t output[4] = {9U, 9U, 9U, 9U}; + CHECK(cache.attach(0U, storage)); + CHECK(!cache.get(key(2U, 1U, 1U), output, 4U)); + CHECK(output[0] == 9U && output[3] == 9U); + CHECK(cache.validCount() == 0U); +} + +void putAndGetCopiesPixels() { + Pyxis::DecodedTileCache cache(4U); + std::uint16_t storage[4] = {}; + const std::uint16_t input[4] = {1U, 2U, 3U, 4U}; + std::uint16_t output[4] = {}; + CHECK(cache.attach(0U, storage)); + CHECK(cache.put(key(3U, 2U, 4U), input, 4U)); + CHECK(cache.get(key(3U, 2U, 4U), output, 4U)); + CHECK(output[0] == 1U && output[1] == 2U && + output[2] == 3U && output[3] == 4U); + CHECK(cache.validCount() == 1U); +} + +void leastRecentlyUsedAttachedEntryIsEvicted() { + Pyxis::DecodedTileCache cache(2U); + std::uint16_t buffers[3][2] = {}; + const std::uint16_t a[2] = {10U, 11U}; + const std::uint16_t b[2] = {20U, 21U}; + const std::uint16_t c[2] = {30U, 31U}; + std::uint16_t output[2] = {}; + for (std::size_t index = 0U; index < 3U; ++index) { + CHECK(cache.attach(index, buffers[index])); + } + CHECK(cache.put(key(4U, 1U, 1U), a, 2U)); + CHECK(cache.put(key(4U, 2U, 2U), b, 2U)); + CHECK(cache.put(key(4U, 3U, 3U), c, 2U)); + CHECK(cache.get(key(4U, 1U, 1U), output, 2U)); + CHECK(cache.put(key(4U, 4U, 4U), a, 2U)); + CHECK(cache.get(key(4U, 1U, 1U), output, 2U)); + CHECK(!cache.get(key(4U, 2U, 2U), output, 2U)); + CHECK(cache.get(key(4U, 3U, 3U), output, 2U)); + CHECK(cache.get(key(4U, 4U, 4U), output, 2U)); + CHECK(cache.validCount() == 3U); +} + +void duplicatePutRefreshesAndReplacesPixels() { + Pyxis::DecodedTileCache cache(2U); + std::uint16_t buffers[2][2] = {}; + const Hardware::TDeck::TileKey a_key = key(5U, 6U, 7U); + const std::uint16_t first[2] = {1U, 2U}; + const std::uint16_t second[2] = {8U, 9U}; + std::uint16_t output[2] = {}; + CHECK(cache.attach(0U, buffers[0])); + CHECK(cache.attach(1U, buffers[1])); + CHECK(cache.put(a_key, first, 2U)); + CHECK(cache.put(a_key, second, 2U)); + CHECK(cache.validCount() == 1U); + CHECK(cache.get(a_key, output, 2U)); + CHECK(output[0] == 8U && output[1] == 9U); +} + +void invalidArgumentsFailClosed() { + Pyxis::DecodedTileCache cache(2U); + std::uint16_t buffer[2] = {}; + const std::uint16_t input[2] = {1U, 2U}; + CHECK(!cache.attach(Pyxis::DecodedTileCache::CAPACITY, buffer)); + CHECK(!cache.attach(0U, nullptr)); + CHECK(cache.attach(0U, buffer)); + CHECK(!cache.put(key(1U, 0U, 0U), input, 1U)); + CHECK(!cache.put(key(1U, 0U, 0U), nullptr, 2U)); + CHECK(!cache.get(key(1U, 0U, 0U), buffer, 1U)); + CHECK(cache.validCount() == 0U); +} +} + +int main() { + missDoesNotMutateOutput(); + putAndGetCopiesPixels(); + leastRecentlyUsedAttachedEntryIsEvicted(); + duplicatePutRefreshesAndReplacesPixels(); + invalidArgumentsFailClosed(); + std::cout << "decoded tile cache: " << passed << " passed, " + << failures << " failed\n"; + return failures == 0 ? 0 : 1; +} diff --git a/tests/native/test_decoded_tile_cache.py b/tests/native/test_decoded_tile_cache.py new file mode 100644 index 00000000..e2b4cb8e --- /dev/null +++ b/tests/native/test_decoded_tile_cache.py @@ -0,0 +1,41 @@ +"""Compile the fixed decoded-tile LRU as strict C++11 under ASan/UBSan.""" +from __future__ import annotations + +import os +from pathlib import Path +import subprocess + +from native_test import find_cxx + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def test_decoded_tile_cache_cpp11_sanitized(tmp_path): + binary = tmp_path / "test_decoded_tile_cache" + command = [ + find_cxx(), + "-std=c++11", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + "-fsanitize=address,undefined", + "-fno-omit-frame-pointer", + f"-I{ROOT / 'lib/tdeck_ui'}", + str(HERE / "test_decoded_tile_cache.cpp"), + str(ROOT / "lib/tdeck_ui/UI/LXMF/DecodedTileCache.cpp"), + "-o", + str(binary), + ] + compiled = subprocess.run(command, capture_output=True, text=True, timeout=60) + assert compiled.returncode == 0, compiled.stdout + compiled.stderr + env = os.environ.copy() + env["ASAN_OPTIONS"] = "detect_leaks=1:halt_on_error=1" + env["UBSAN_OPTIONS"] = "halt_on_error=1:print_stacktrace=1" + ran = subprocess.run( + [str(binary)], capture_output=True, text=True, timeout=60, env=env + ) + assert ran.returncode == 0, ran.stdout + ran.stderr + assert "decoded tile cache:" in ran.stdout + assert "0 failed" in ran.stdout