From 02398c8a968f60cfbef856a4d26726aa59ed1970 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Tue, 14 Jul 2026 13:21:15 -0400 Subject: [PATCH] Add memory profiling for EPUB and heap diagnostics Adds residentBytes() and cssRuleCount() methods to track EPUB memory usage including parsed CSS. Enhances memory audit logging with EPUB heap usage, max allocatable block size, and CSS rule count. Implements one-shot heap block map dump on first settled audit to identify fragmentation and long-lived allocations. --- lib/Epub/Epub.h | 11 +++ lib/Epub/Epub/css/CssParser.h | 13 +++ src/activities/reader/EpubReaderActivity.cpp | 89 +++++++++++++++++++- 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index 3c8ed39b..b3621c09 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -44,6 +44,17 @@ class Epub { } ~Epub() = default; std::string& getBasePath() { return contentBasePath; } + // Approximate resident heap of the open book (audit): path strings, the CSS + // file list, and the parsed stylesheet. BookMetadataCache is file-backed + // (counts + HalFile handles) and contributes little. + size_t residentBytes() const { + size_t total = sizeof(Epub) + tocNcxItem.capacity() + tocNavItem.capacity() + filepath.capacity() + + contentBasePath.capacity() + cachePath.capacity(); + for (const auto& f : cssFiles) total += sizeof(f) + (f.capacity() > 15 ? f.capacity() : 0); + if (cssParser) total += cssParser->residentBytes(); + return total; + } + size_t cssRuleCount() const { return cssParser ? cssParser->ruleCount() : 0; } bool load(bool buildIfMissing = true, bool skipLoadingCss = false); bool clearCache() const; void setupCacheDir() const; diff --git a/lib/Epub/Epub/css/CssParser.h b/lib/Epub/Epub/css/CssParser.h index 41797fd3..3fe74f04 100644 --- a/lib/Epub/Epub/css/CssParser.h +++ b/lib/Epub/Epub/css/CssParser.h @@ -67,6 +67,19 @@ class CssParser { */ [[nodiscard]] static CssStyle parseInlineStyle(std::string_view styleValue); + // Approximate resident heap of the parsed stylesheet, for the audit log. + // unordered_map cost model: bucket array + one node per rule (libstdc++ node + // overhead ~= 2 pointers + hash) + key string capacity when it exceeds SSO. + size_t residentBytes() const { + size_t total = rulesBySelector_.bucket_count() * sizeof(void*); + for (const auto& kv : rulesBySelector_) { + total += sizeof(void*) * 2 + sizeof(size_t); // node overhead + total += sizeof(kv); + if (kv.first.capacity() > 15) total += kv.first.capacity(); // beyond SSO + } + return total; + } + /** * Check if any rules have been loaded */ diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index e37d6e15..68b24165 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -39,6 +39,8 @@ #include "util/BookmarkUtil.h" #include "util/ScreenshotUtil.h" #include "SdCardFontSystem.h" +#include +#include "rom/ets_sys.h" namespace { // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() @@ -68,6 +70,45 @@ bool isInReadFolder(const std::string& path) { return path.size() > n && path.compare(0, n, READ_FOLDER) == 0 && path[n] == '/'; } +// Heap block map capture. heap_caps_dump walks each heap inside a critical +// section (interrupts masked), so its output can neither go through the +// UART-0 ROM path we can't see nor be flow-controlled toward the CDC (the CDC +// ring drains in an interrupt handler — waiting deadlocks into the interrupt +// WDT, field-verified). Instead the ROM putc parses each line into a compact +// record; the captured table is logged after the dump with interrupts live. +namespace heapmap { +struct BlockRec { + uint32_t addr; + uint32_t size; + bool free; +}; +constexpr uint16_t kMaxRecs = 1400; +BlockRec* recs = nullptr; // borrowed buffer, valid only during capture +uint16_t recCount = 0; +bool overflowed = false; +char line[96]; +uint8_t lineLen = 0; + +void putc(char c) { + if (c != '\n') { + if (lineLen < sizeof(line) - 1) line[lineLen++] = c; + return; + } + line[lineLen] = '\0'; + lineLen = 0; + // e.g. "Block 0x3fcc69bc data, size: 89424 bytes, Free: Yes" + unsigned addr = 0, size = 0; + char freeWord[4] = {0}; + if (sscanf(line, "Block 0x%x data, size: %u bytes, Free: %3s", &addr, &size, freeWord) == 3) { + if (recs && recCount < kMaxRecs) { + recs[recCount++] = {addr, size, freeWord[0] == 'Y'}; + } else { + overflowed = true; + } + } +} +} // namespace heapmap + class FrameBufferBuildLoan { public: explicit FrameBufferBuildLoan(GfxRenderer& renderer) : renderer_(renderer) {} @@ -1509,13 +1550,55 @@ void EpubReaderActivity::render(RenderLock&& lock) { // metadata + BLE (when on) + anything not yet instrumented. const size_t fontBytes = sdFontSystem.reportFontMemory(); const size_t sectionBytes = section ? section->residentBytes() : 0; + const size_t epubBytes = epub ? epub->residentBytes() : 0; const size_t fbBytes = renderer.hasFrameBuffer() ? renderer.getBufferSize() : 0; const uint32_t heapTotal = ESP.getHeapSize(); const uint32_t heapFree = ESP.getFreeHeap(); - LOG_DBG("MEM", "audit: total=%u free=%u fb=%u fonts=%u section=%u building=%d ble=%d other=%d", heapTotal, - heapFree, (unsigned)fbBytes, (unsigned)fontBytes, (unsigned)sectionBytes, + LOG_DBG("MEM", + "audit: total=%u free=%u maxAlloc=%u fb=%u fonts=%u section=%u epub=%u cssRules=%u building=%d ble=%d " + "other=%d", + heapTotal, heapFree, (unsigned)ESP.getMaxAllocHeap(), (unsigned)fbBytes, (unsigned)fontBytes, + (unsigned)sectionBytes, (unsigned)epubBytes, (unsigned)(epub ? epub->cssRuleCount() : 0), section && section->isBuilding() ? 1 : 0, BleHid.isRunning() ? 1 : 0, - (int)(heapTotal - heapFree - fbBytes - fontBytes - sectionBytes)); + (int)(heapTotal - heapFree - fbBytes - fontBytes - sectionBytes - epubBytes)); + // One-shot heap block map for fragmentation analysis: dumps every block + // (address/size/free) once per boot, at the first settled (non-building) + // audit, so mid-heap long-lived allocations identify themselves. ~2-4 s + // of serial output; debug builds only. + static bool heapMapDumped = false; + if (!heapMapDumped && !(section && section->isBuilding())) { + heapMapDumped = true; + auto recBuf = makeUniqueNoThrow(heapmap::kMaxRecs); + if (recBuf) { + heapmap::recs = recBuf.get(); + heapmap::recCount = 0; + heapmap::overflowed = false; + heapmap::lineLen = 0; + // Capture (interrupts masked inside the dump): parse into records, + // never wait. Log the table afterward with the system live. + ets_install_putc1(&heapmap::putc); + heap_caps_dump(MALLOC_CAP_8BIT); + ets_install_uart_printf(); + heapmap::recs = nullptr; + + LOG_DBG("MEM", "---- heap block map: %u blocks%s ----", heapmap::recCount, + heapmap::overflowed ? " (TRUNCATED)" : ""); + uint32_t dustCount = 0, dustBytes = 0; + for (uint16_t i = 0; i < heapmap::recCount; ++i) { + const auto& r = recBuf[i]; + if (r.free || r.size >= 256) { + LOG_DBG("MEM", "%s 0x%08x %u", r.free ? "FREE" : "used", r.addr, r.size); + } else { + dustCount++; + dustBytes += r.size; + } + } + LOG_DBG("MEM", "dust: %u used blocks < 256B totaling %u bytes", dustCount, dustBytes); + LOG_DBG("MEM", "---- end heap block map ----"); + } else { + LOG_ERR("MEM", "heap map skipped: no room for capture buffer"); + } + } } } // Only persist when the position actually changed. render() also runs on menu,