Optimize memory allocation in BookPaginator

Replace fixed-size arena buffers with two-pass allocation strategy. First pass parses book into temporary full-size arena to determine actual memory footprint (5-40 KB vs 48 KB cap), then reallocates exact-sized buffers. This reduces peak memory usage by avoiding simultaneous allocation of all arenas with chapter-build scratch buffer (~100 KB) on constrained heap (~140 KB free). Stylesheet is also compacted to actual size, and all open-time transients are freed before per-chapter arenas are allocated.
This commit is contained in:
Justin Mitchell
2026-07-08 02:17:29 -04:00
parent 78c71f1c77
commit e027f60bf0
3 changed files with 193 additions and 72 deletions
+162 -57
View File
@@ -5,6 +5,7 @@
#include <HalStorage.h>
#include <Logging.h>
#include <Memory.h>
#include <esp_heap_caps.h>
#include <text/hyph_en_us.h>
#include <cstring>
@@ -57,21 +58,6 @@ bool BookPaginator::open(const std::string& path, const std::string& cacheDir, G
const bool forcePlainText) {
close();
bookBuf_ = makeUniqueNoThrow<uint8_t[]>(kBookArenaSize);
indexBuf_ = makeUniqueNoThrow<uint8_t[]>(kIndexArenaSize);
pageBuf_ = makeUniqueNoThrow<uint8_t[]>(kPageArenaSize);
sheetBuf_ = makeUniqueNoThrow<uint8_t[]>(kSheetArenaSize);
if (!bookBuf_ || !indexBuf_ || !pageBuf_ || !sheetBuf_) {
LOG_ERR("FIB", "OOM: paginator arenas (%u B)",
static_cast<unsigned>(kBookArenaSize + kIndexArenaSize + kPageArenaSize + kSheetArenaSize));
close();
return false;
}
bookArena_.init(bookBuf_.get(), kBookArenaSize);
indexArena_.init(indexBuf_.get(), kIndexArenaSize);
pageArena_.init(pageBuf_.get(), kPageArenaSize);
sheetArena_.init(sheetBuf_.get(), kSheetArenaSize);
if (!source_.open(path.c_str())) {
LOG_ERR("FIB", "Cannot open book file: %s", path.c_str());
close();
@@ -82,37 +68,87 @@ bool BookPaginator::open(const std::string& path, const std::string& cacheDir, G
isTxt_ = forcePlainText || (len > 4 && strcasecmp(path.c_str() + len - 4, ".txt") == 0);
if (!isTxt_) {
// Container open + book stylesheet need parse scratch; both are
// once-per-open, so the big build buffer is borrowed transiently.
auto scratchBuf = makeUniqueNoThrow<uint8_t[]>(kBuildScratchSize);
if (!scratchBuf) {
LOG_ERR("FIB", "OOM: open scratch (%u B)", static_cast<unsigned>(kBuildScratchSize));
// The whole book budget must coexist with a ~100+ KB chapter-build
// scratch on a heap where the reader sees ~140 KB free, so worst-case
// arena sizing is unaffordable. Open in two passes: parse into a
// full-size TEMPORARY arena to learn the book's real footprint (corpus
// high-water is 5-40 KB against the 48 KB cap), then re-open into an
// exactly-sized buffer. The stylesheet is likewise compacted down to its
// real rule array. All open-time transients are freed before the
// per-chapter arenas are allocated.
auto scratchBuf = makeUniqueNoThrow<uint8_t[]>(kOpenScratchSize);
auto tempBookBuf = makeUniqueNoThrow<uint8_t[]>(kBookArenaSize);
if (!scratchBuf || !tempBookBuf) {
LOG_ERR("FIB", "OOM: open working set (%u B, free heap %u)",
static_cast<unsigned>(kOpenScratchSize + kBookArenaSize), static_cast<unsigned>(ESP.getFreeHeap()));
close();
return false;
}
Arena scratch(scratchBuf.get(), kBuildScratchSize);
Arena scratch(scratchBuf.get(), kOpenScratchSize);
bookArena_.init(tempBookBuf.get(), kBookArenaSize);
const BookStatus st = book_.open(source_, bookArena_, scratch);
BookStatus st = book_.open(source_, bookArena_, scratch);
if (st != BookStatus::Ok) {
LOG_ERR("FIB", "Book open failed: %d (%s)", static_cast<int>(st), path.c_str());
close();
return false;
}
CssStylesheetBuilder builder;
if (builder.begin(sheetArena_)) {
for (size_t m = 0; m < book_.manifestCount(); ++m) {
const ManifestItem* item = book_.manifestItem(m);
if (item == nullptr || item->mediaType == nullptr || strcmp(item->mediaType, "text/css") != 0) continue;
if (const freeink::book::ZipEntry* e = book_.zip().find(item->href)) {
builder.addSheet(source_, *e, scratch);
// Second pass into the exact footprint (+ slack for alignment). The
// temporary arena is freed FIRST — the reopen re-parses the container
// rather than copying, so peak in-flight stays at scratch + one arena.
const size_t bookUsed = bookArena_.used();
tempBookBuf.reset();
book_ = freeink::book::Book();
bookBuf_ = makeUniqueNoThrow<uint8_t[]>(bookUsed + 128);
if (!bookBuf_) {
LOG_ERR("FIB", "OOM: book arena reopen (%u B)", static_cast<unsigned>(bookUsed + 128));
close();
return false;
}
bookArena_.init(bookBuf_.get(), bookUsed + 128);
st = book_.open(source_, bookArena_, scratch);
if (st != BookStatus::Ok) {
LOG_ERR("FIB", "Exact-size reopen failed: %d", static_cast<int>(st));
close();
return false;
}
// Stylesheet: build in a temporary working arena, keep only the rules.
auto tempSheetBuf = makeUniqueNoThrow<uint8_t[]>(kSheetArenaSize);
if (tempSheetBuf) {
Arena sheetArena(tempSheetBuf.get(), kSheetArenaSize);
CssStylesheetBuilder builder;
if (builder.begin(sheetArena)) {
for (size_t m = 0; m < book_.manifestCount(); ++m) {
const ManifestItem* item = book_.manifestItem(m);
if (item == nullptr || item->mediaType == nullptr || strcmp(item->mediaType, "text/css") != 0) continue;
if (const freeink::book::ZipEntry* e = book_.zip().find(item->href)) {
builder.addSheet(source_, *e, scratch);
}
}
sheet_ = builder.finish();
if (builder.skippedSheets() > 0) {
LOG_INF("FIB", "%u stylesheet(s) skipped (size cap)", builder.skippedSheets());
}
if (sheet_.ruleCount > 0) {
// CssRule is self-contained (name hashes + POD declaration), so the
// rule array can move out of the 12 KB working arena wholesale.
const size_t rulesBytes = sheet_.ruleCount * sizeof(freeink::book::CssRule);
sheetBuf_ = makeUniqueNoThrow<uint8_t[]>(rulesBytes);
if (sheetBuf_) {
memcpy(sheetBuf_.get(), sheet_.rules, rulesBytes);
sheet_.rules = reinterpret_cast<const freeink::book::CssRule*>(sheetBuf_.get());
} else {
sheet_ = freeink::book::CssStylesheet{}; // OOM: lay out with element defaults
}
}
}
sheet_ = builder.finish();
if (builder.skippedSheets() > 0) {
LOG_INF("FIB", "%u stylesheet(s) skipped (size cap)", builder.skippedSheets());
}
}
LOG_INF("FIB", "Book open: %u spine items, book arena %u B (exact), %u CSS rules, free heap %u",
static_cast<unsigned>(book_.spineCount()), static_cast<unsigned>(bookUsed), sheet_.ruleCount,
static_cast<unsigned>(ESP.getFreeHeap()));
}
cache_.setDir(cacheDir.c_str());
@@ -123,9 +159,13 @@ bool BookPaginator::open(const std::string& path, const std::string& cacheDir, G
}
loadHyphenator();
// Per-chapter arenas last, once the open-time transients are gone.
if (!reallocChapterArenas()) {
close();
return false;
}
open_ = true;
LOG_DBG("FIB", "Book open: %u spine items, book arena %u/%u B", static_cast<unsigned>(spineCount()),
static_cast<unsigned>(bookArena_.used()), static_cast<unsigned>(kBookArenaSize));
return true;
}
@@ -261,6 +301,23 @@ void BookPaginator::configureLayout(const int16_t pageWidth, const int16_t pageH
}
}
// (Re)allocates the per-chapter arenas released around a build's big scratch
// block. On failure the paginator stays book-open but chapter-less: callers
// see chapterReady() == false and surface the error.
bool BookPaginator::reallocChapterArenas() {
if (!indexBuf_) indexBuf_ = makeUniqueNoThrow<uint8_t[]>(kIndexArenaSize);
if (!pageBuf_) pageBuf_ = makeUniqueNoThrow<uint8_t[]>(kPageArenaSize);
if (!indexBuf_ || !pageBuf_) {
LOG_ERR("FIB", "OOM: chapter arenas (%u B)", static_cast<unsigned>(kIndexArenaSize + kPageArenaSize));
indexBuf_.reset();
pageBuf_.reset();
return false;
}
indexArena_.init(indexBuf_.get(), kIndexArenaSize);
pageArena_.init(pageBuf_.get(), kPageArenaSize);
return true;
}
uint32_t BookPaginator::fontFingerprint() const {
uint32_t hash = 2166136261u;
for (uint8_t i = 0; i < ladderCount_; ++i) {
@@ -292,35 +349,80 @@ freeink::book::BookStatus BookPaginator::ensureChapter(const uint16_t spineIndex
if (isTxt_ && spineIndex != 0) return BookStatus::NotFound;
// The pagination working set (layout buffers + one inflate stream + the
// writer's page index) lives only for this call.
auto scratchBuf = makeUniqueNoThrow<uint8_t[]>(kBuildScratchSize);
// writer's page index) lives only for this call — but it needs one big
// CONTIGUOUS block, and everything else the parse touches does NOT come
// from this arena: expat's internal pools allocate from the system heap.
// Grabbing the largest possible block starved exactly that — the heap
// dipped to ~6 KB mid-parse and a failed expat malloc surfaces as a bogus
// ParseError. So size adaptively: as much as available AFTER a reserve for
// the parser's heap use, capped at the ideal, floored at what an ordinary
// text chapter needs. Free the idle per-chapter arenas first so their
// space can coalesce into the block.
reader_ = freeink::book::PageCacheReader();
indexBuf_.reset();
pageBuf_.reset();
// Expat pools + misc system-heap use during the parse. Measured on device:
// a 28 KB reserve bottomed out at ~8-10 KB free mid-build, so ~19 KB is
// real parser use; 36 KB keeps a comfortable floor without starving the
// scratch (typical chapters use ~76 KB of it).
constexpr size_t kParserHeapReserve = 36 * 1024;
constexpr size_t kScratchFloor = 88 * 1024; // text-chapter working set + writer index
const size_t freeHeap = ESP.getFreeHeap();
const size_t largestBlock = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT);
size_t scratchSize = kBuildScratchSize;
if (freeHeap > kParserHeapReserve && freeHeap - kParserHeapReserve < scratchSize) {
scratchSize = freeHeap - kParserHeapReserve;
}
if (largestBlock > 64 && largestBlock - 64 < scratchSize) { // allocator header slack
scratchSize = largestBlock - 64;
}
std::unique_ptr<uint8_t[]> scratchBuf;
if (scratchSize >= kScratchFloor) {
scratchBuf = makeUniqueNoThrow<uint8_t[]>(scratchSize);
}
if (!scratchBuf) {
LOG_ERR("FIB", "OOM: build scratch (%u B, free heap %u)", static_cast<unsigned>(kBuildScratchSize),
static_cast<unsigned>(ESP.getFreeHeap()));
LOG_ERR("FIB", "OOM: build scratch (want >= %u B, free heap %u, max block %u)",
static_cast<unsigned>(kScratchFloor), static_cast<unsigned>(freeHeap), static_cast<unsigned>(largestBlock));
reallocChapterArenas(); // restore the per-chapter arenas for the caller
return BookStatus::OutOfMemory;
}
Arena scratch(scratchBuf.get(), kBuildScratchSize);
if (scratchSize != kBuildScratchSize) {
LOG_INF("FIB", "Build scratch sized to %u B (free heap %u, max block %u)", static_cast<unsigned>(scratchSize),
static_cast<unsigned>(freeHeap), static_cast<unsigned>(largestBlock));
}
Arena scratch(scratchBuf.get(), scratchSize);
const uint32_t t0 = millis();
PageCacheWriter writer;
if (!writer.begin(cache_, cacheName_, gen, scratch)) {
return BookStatus::IoError;
}
{
PageCacheWriter writer;
if (!writer.begin(cache_, cacheName_, gen, scratch)) {
scratchBuf.reset();
reallocChapterArenas();
return BookStatus::IoError;
}
ProgressSink sink(writer, progress);
uint32_t totalChars = 0;
st = isTxt_ ? ChapterLayout::layoutPlainText(source_, params_, scratch, sink, nullptr, &totalChars)
: ChapterLayout::layout(source_, book_.zip(), *entry, item->href, params_, scratch, sink, nullptr,
&totalChars);
writer.setTotalChars(totalChars);
if (st == BookStatus::Ok && !writer.finish()) st = BookStatus::IoError;
if (st != BookStatus::Ok) {
LOG_ERR("FIB", "Chapter %u layout failed: %d (scratch high water %u B)", spineIndex, static_cast<int>(st),
static_cast<unsigned>(scratch.highWater()));
return st;
ProgressSink sink(writer, progress);
uint32_t totalChars = 0;
st = isTxt_ ? ChapterLayout::layoutPlainText(source_, params_, scratch, sink, nullptr, &totalChars)
: ChapterLayout::layout(source_, book_.zip(), *entry, item->href, params_, scratch, sink, nullptr,
&totalChars);
writer.setTotalChars(totalChars);
if (st == BookStatus::Ok && !writer.finish()) st = BookStatus::IoError;
if (st != BookStatus::Ok) {
LOG_ERR("FIB", "Chapter %u layout failed: %d (scratch high water %u/%u B)", spineIndex, static_cast<int>(st),
static_cast<unsigned>(scratch.highWater()), static_cast<unsigned>(scratchSize));
scratchBuf.reset();
reallocChapterArenas();
return st;
}
LOG_INF("FIB", "Chapter %u paginated: %u pages in %ums (scratch high water %u/%u B, free heap %u)", spineIndex,
writer.pageCount(), static_cast<unsigned>(millis() - t0), static_cast<unsigned>(scratch.highWater()),
static_cast<unsigned>(scratchSize), static_cast<unsigned>(ESP.getFreeHeap()));
}
LOG_INF("FIB", "Chapter %u paginated: %u pages in %ums (scratch high water %u B)", spineIndex, writer.pageCount(),
static_cast<unsigned>(millis() - t0), static_cast<unsigned>(scratch.highWater()));
scratchBuf.reset();
if (!reallocChapterArenas()) return BookStatus::OutOfMemory;
indexArena_.reset();
st = reader_.open(cache_, cacheName_, gen, indexArena_);
@@ -334,6 +436,9 @@ bool BookPaginator::charForAnchor(const char* fragment, uint32_t* charOut) const
}
freeink::book::BookStatus BookPaginator::readPage(const uint32_t pageIndex, freeink::book::Page* out) {
// pageBuf_ is released transiently around chapter builds; a null here means
// a failed reallocation (the arena would point at freed memory).
if (curSpine_ == kNoSpine || !pageBuf_) return BookStatus::NotFound;
pageArena_.reset();
return reader_.readPage(pageIndex, pageArena_, out);
}
+25 -14
View File
@@ -5,15 +5,19 @@
// half of the reader; EpubReaderActivity keeps the UI half (input, menus,
// status bar, refresh cadence) and talks to the engine only through this.
//
// Memory model (ESP32-C3, no PSRAM — all buffers heap-allocated once in
// open() and freed in close()):
// book arena 64 KB ZIP catalog + metadata + spine + TOC (corpus
// high-water 5-40 KB; omnibus containers that exceed
// it fail with a clean OutOfMemory)
// index arena 16 KB current chapter's page index + anchor table
// page arena 16 KB decoded runs of the page being rendered
// build scratch (transient, ~120 KB) allocated only while a chapter
// (re)paginates — the layout engine's whole working set
// Memory model (ESP32-C3, no PSRAM; the reader sees only ~140 KB free, so
// nothing is sized for the worst case):
// book arena exact-sized: open() parses into a temporary 48 KB arena to
// learn the real footprint (corpus high-water 5-40 KB), then
// re-opens into precisely that many bytes
// stylesheet compacted to the actual rule array (typically < 2 KB)
// index arena 12 KB current chapter's page index + anchor table
// page arena 12 KB decoded runs of the page being rendered
// open scratch (transient 64 KB) container parse working set
// build scratch (transient, ideally 120 KB, stepping down to 96 KB under
// fragmentation) only while a chapter (re)paginates; the
// index/page arenas are released around it to help the heap
// produce one contiguous block
// Steady-state page turns touch only the page arena (~2 KB used).
#include <FreeInkBook.h>
@@ -123,9 +127,17 @@ class BookPaginator {
static constexpr size_t kIndexArenaSize = 12 * 1024;
static constexpr size_t kPageArenaSize = 12 * 1024;
static constexpr size_t kSheetArenaSize = 12 * 1024;
// Container open: one inflate stream + package parse (~47 KB measured
// high-water). Deliberately snug: open needs scratch + the 48 KB temp book
// arena simultaneously, and the C3's largest free block hovers just above
// 112 KB — 64 KB here made the pair miss fitting by a few dozen bytes.
static constexpr size_t kOpenScratchSize = 56 * 1024;
// Chapter pagination: layout buffers + inflate + writer index. The ideal
// size; ensureChapter steps down when fragmentation denies a block this big.
static constexpr size_t kBuildScratchSize = 120 * 1024;
bool buildFontChain(GfxRenderer& renderer);
bool reallocChapterArenas();
void loadHyphenator();
uint32_t fontFingerprint() const;
@@ -144,14 +156,13 @@ class BookPaginator {
freeink::book::Hyphenator hyphenator_;
std::unique_ptr<uint8_t[]> hyphBlob_; // SD-loaded patterns (non-English books)
std::unique_ptr<uint8_t[]> bookBuf_;
std::unique_ptr<uint8_t[]> indexBuf_;
std::unique_ptr<uint8_t[]> pageBuf_;
std::unique_ptr<uint8_t[]> sheetBuf_;
std::unique_ptr<uint8_t[]> bookBuf_; // exact-sized book arena backing
std::unique_ptr<uint8_t[]> indexBuf_; // released transiently around builds
std::unique_ptr<uint8_t[]> pageBuf_; // released transiently around builds
std::unique_ptr<uint8_t[]> sheetBuf_; // compacted CssRule array
freeink::book::Arena bookArena_;
freeink::book::Arena indexArena_;
freeink::book::Arena pageArena_;
freeink::book::Arena sheetArena_;
freeink::book::CssStylesheet sheet_{};
// PageCacheReader borrows this for readPage() — must outlive the reader.
+6 -1
View File
@@ -20,7 +20,12 @@ class SdBookSource : public freeink::book::BookSource {
size_ = file_.fileSize64();
return size_ > 0;
}
void close() { file_.close(); }
// HalFile methods assert on a never-opened handle (impl == nullptr), so a
// close on a fresh/already-closed source must be a no-op — BookPaginator
// calls close() defensively before every open and from its destructor.
void close() {
if (file_.isOpen()) file_.close();
}
int32_t readAt(uint64_t offset, void* dst, uint32_t len) override {
if (!file_.isOpen() || !file_.seek64(offset)) return -1;
return file_.read(dst, len);