Skip to content

Navigation performance: library walk, shelf paint, settings heap peak - #54

Open
imshentastic wants to merge 15 commits into
mainfrom
feat/dict-casper
Open

Navigation performance: library walk, shelf paint, settings heap peak#54
imshentastic wants to merge 15 commits into
mainfrom
feat/dict-casper

Conversation

@imshentastic

Copy link
Copy Markdown
Owner

Continues the perf pass from HANDOFF-PERF-REMAINING.md. All numbers below
are measured on the user's X4 (43 books, 35 sleep images) from PERF /
RPROF serial captures, before and after each change.

What changed

Library walk reuse across silent restarts. A navigation "loading moment"
on this device is a deliberate reboot, and each one re-walked the SD card it
had walked seconds earlier. An RTC-backed marker now lets a clean silent
restart reuse the previous boot's walk. Cold boot, power-cycle and crash
restarts always re-walk, so a swapped card is never trusted.

The correctness hazard is a book added or removed before the restart without
the index hearing about it -- the walk is how new books are discovered.
Arming is latched off for the rest of the boot by
CrossPointWebServer::begin(), which covers WS upload, HTTP upload, the file
manager's delete/rename and every WebDAV write verb in one place. Notably
Calibre Connect starts the same server, never touched LibraryIndex, and
silent-restarts on exit -- a per-endpoint audit would have had to stay
exhaustive forever.

Concurrent library walks (data race). Adding timing to the walk showed
two per cold boot with overlapping intervals. Activity::render() runs on
the render task, Activity::loop() on the main task holding no RenderLock,
and HomeActivity reaches the index from both. ensureWalked()'s guard was a
check-then-set spanning the whole ~1.2 s walk, so both tasks passed it.

The wasted second was the lesser problem: rescan() builds string_views
into the live path pool and then swaps all three pools wholesale, so a
concurrent rescan or releaseMemory() leaves the other task's views
dangling. Plausibly behind some of the unexplained reboots around Home.

Shelf paint. Two changes: probe the cached thumbnail first in
loadShelfCovers (an already-cached cell was paying four SD lookups to
answer a one-lookup question, including one hidden inside the Epub
constructor), and cache pre-baked cover tiles via the existing CoverTiles
mechanism under a new role. The tile matters because a .tile read needs no
decode and no image-cache budget -- the cold-boot shelf was slow because the
library walk fragments the heap just before it paints, so the bitmap cache
refused and every cell fell back to a streamed decode.

Settings heap peak. The base settings list reserved 64 but adds 69
entries, so a process-lifetime static sat at capacity 128 -- ~13 KB resident
forever on a ~380 KB device. getSettingsList() also copy-constructed at
exact capacity then always appended two rows, forcing a full reallocation.
Plus reserves on all 18 submenu vectors.

Measured

before after
walks per cold boot 2 (1204 + 980 ms) 1 (991 ms)
Home-target silent restart full walk walk skipped
loadShelfCovers 4 SD stats/cell 42-48 ms, 4/4 probe hits
shelf stage, steady state 285-306 ms 244-264 ms
shelf stage, silent restart 1277 ms 264 ms
rebuildSettingsLists peak -19196 B -16620 B
maxAlloc at Settings entry 31732 51188

That last row is the one that removes restarts rather than making them
faster: the pre-flight floor is 30 KB, so it went from clearing by 1.7 KB to
clearing by 21 KB.

Still open

  • No cold boot has yet painted the shelf with tiles already baked. Expected
    to land near 250 ms against the 1563 ms measured pre-tiles, but unverified.
  • boot->ready on a cold boot can include ~3.6 s of blocking NTP
    (runBootTimeNtpSyncOverBootScreen) when the clock is unset. Pre-existing,
    logs nothing, unrelated to this branch.
  • The cps:serialize reboot on Settings exit and
    getSpineCumulativeSize index 8507 in RecentBooksGrid both still
    reproduce. Pre-existing, listed in the handoff.
  • The simulator env does not build at HEAD (CollectionsStore.cpp calls
    Storage.writeFileWithBackup and String::reserve, absent from the
    simulator's HAL shim). Untouched here; tiny-bitter builds clean.

Item D (refresh-mode audit) remains on hold per the handoff.

Build: pio run -e tiny-bitter, 5,390,448 bytes, 1136 KB under the 6,553,600
cap.

The QC drain gated its prompt purely on pendingBleQuickConnectPromptStage_
and ignored btManifestPromptAnsweredThisSession_, so answering the prompt
at book open and then hitting BT Quick Connect asked the same question
again. v187 wired the flag into the edge-detect site only; this completes
it. A surviving mismatch implies the earlier answer was 'use my settings',
so the drain proceeds to Step 2 with the user's settings intact.

Found by a parallel session; the answered-flag is already RTC-backed here
(v187 sidecar), so the fix holds across the BT pre-flight silent restarts
too.
Both default OFF; with them off the cycle path is byte-for-byte the
old behaviour, including tap-detection timing.

- sleepCycleDoubleTapBack: a second Power tap within 400ms steps to the
  previous image. The window is only polled when the setting is on, so
  the default path adds no delay before deep sleep. Backward displays
  cursor-2 (the cursor stores the NEXT image), keeping the invariant
  that back-after-forward returns to the previous picture.
- sleepCycleDailyMode: automatic sleeps hold the current image until the
  calendar day rolls over; manual taps always advance and re-stamp the
  day. Day key is YYYYMMDD compared for equality, so no epoch math.
  With no valid clock the gate is inert and per-sleep cycling continues,
  and enabling the setting without a clock pushes ClockSyncActivity
  inline (mirrors the Home-clock toggle precedent) so X4 users get NTP
  right away.
Conflict was comment-only in the Quick-Connect prompt gate: main landed
the same one-line fix (70bae01) with a fuller comment, kept that.
Main's kinsoku implementation (no-break-before AND no-break-after tables,
upstream #2288) supersedes the leading-punctuation rule from ba80e2a.
Registering in SettingsList only reaches the flat list and the web
settings page; the on-device menu builds a curated tree, so both
toggles were invisible on the device. Push them into Display > Sleep
Image next to the existing cycle-on-tap row.
checkAutoReconnect calls connectToDevice(), which blocks the main loop
2-3 s when the bonded remote is off or out of range. It is driven by
local button presses, so anyone who owns a page-turner but is not using
it right now paid that freeze press after press -- a large part of the
'buttons feel slow' reports. Count consecutive failures and hold off
10s/20s/40s/80s/160s, capped at 5 min; a successful connect or any
deliberate BT enable clears it, so 'connect now' is never delayed.

Also add the timing needed to target the rest: boot->ready (which IS
the pause users see, since navigation silent-restarts to defragment)
tagged with the restart target, and onEnter duration per activity when
it exceeds 150 ms.
Settings was the one common navigation target missing from
isLibraryLightBoot, so a Settings-entry restart -- the restart most
likely to fire, since the 45 KB free / 30 KB maxAlloc pre-flight trips
after a reading session has fragmented the heap -- paid a full
LibraryIndex + SeriesIndex + CollectionsStore load that no Settings
screen reads.

Rebuild Author Keys is the only Settings action that touches the index;
it now calls the idempotent begin() first. Without that it would have
cleared an empty index and saved it over the user's real one.
Field timing (X4, 35 sleep images, 23 transparent/grey): this scan cost
~1.8 s of a ~6.9 s boot->ready on a navigation silent-restart, on every
boot, to reach the same '0 unbaked' answer. Deciding a file is
unbakeable means opening and parsing its PNG/BMP header, and an
unbakeable file never becomes bakeable, so the work was pure repetition.

Cache '<fileCount> <unbaked>' beside the declined sidecar and re-probe
only when the sleep dir's file count changes (upload/delete) -- the only
way the answer moves on its own. Choosing 'Bake now' drops the cache,
since baking changes the verdict without changing the count. The
directory listing is still walked every time, so an added or removed
file is always noticed.
Field log: 'flushIfDirtyNow SKIPPED: maxAlloc=19444 below hard floor
20480 -- setting change lost across restart'. The floor is correct (it
prevents a bad_alloc terminate inside the 102-field JSON build) but the
outcome was that a setting the user deliberately changed silently
vanished.

The deep-sleep path already solves this by lending the framebuffer's
~48 KB to the write. Do the same on the silent-restart path, then
restore the buffer so the seamless snapshot still runs. Freeing and
immediately re-taking the same block makes the realloc very likely to
succeed; if it fails, the snapshot's existing null-guard skips the
repaint -- losing a cosmetic no-flash transition beats losing the
setting. One hook covers all 23 restart sites.
…walks

Two problems in one area, found by adding PERF timing around the SD walk.

Reuse across silent restarts. LibraryIndex::ensureWalked() is gated by
walkPerformed, a plain RAM bool that resets every reboot, so a silent
restart re-walked the card it had walked seconds earlier (~1.0-1.2 s on a
43-book library). An RTC-backed marker now mirrors walkPerformed and is
honoured only for a clean silent restart with a loaded on-disk index; cold
boot, power-cycle and crash restarts always walk, so a swapped SD card is
never trusted.

The hazard is a book appearing or disappearing before the restart without
the index learning about it -- the walk is how new books are discovered, so
a stale marker would hide a just-uploaded book until a power cycle. The
marker is armed only by a completed rescan() and cleared by markStale() /
forgetPath() / releaseMemory(). Arming is then latched off for the rest of
the boot by CrossPointWebServer::begin(), which is the real chokepoint: the
server's WS upload, HTTP upload, file-manager delete/rename and every
WebDAV write verb change the card directly, and two separate activities
start one. Calibre Connect in particular never touched LibraryIndex at all
and silent-restarts on exit, so a per-endpoint audit would have to stay
exhaustive forever; gating at begin() covers current and future paths by
construction. Calibre Connect also now markStale()s on exit, matching File
Transfer.

Concurrent walks. The same PERF line showed two walks per cold boot with
overlapping intervals -- [4671,5875] and [5552,6532]. Activity::render()
runs on the ActivityManagerRender task while Activity::loop() runs on the
main task holding no RenderLock, and HomeActivity reaches the index from
both: loop() calls cachedShelfPaths() to size the shelf for navigation,
render() calls it to paint. Both land in resolveBookPaths -> ensureWalked(),
whose guard was a check-then-set spanning the entire ~1.2 s walk, so both
tasks passed it.

The cost was the lesser issue. rescan() builds a map of string_views into
the live pathPool and then swaps entries/pathPool/authorKeyPool wholesale,
so a concurrent rescan or releaseMemory() leaves the other task's views
dangling -- a plausible contributor to the unexplained reboots around Home.
walkPerformed is now atomic and a mutex serialises ensureWalked, rescan,
releaseMemory and forgetPath, with an unlocked fast path so steady-state
renders stay lock-free. markStale() is deliberately left unlocked; the
reasoning is in the code.

Measured on an X4, 43 books: cold boot 2 walks -> 1, and a Home-target
silent restart skips the walk entirely.
Every check in the loadShelfCovers loop exists to decide whether a cover
thumbnail needs generating, but on a settled library the thumbnail is
already on SD and none of them matter. Ordered as it was, an already-cached
cell paid four SD lookups before reaching the one that answers the question:

  Storage.exists(bookPath)            stat 1
  CoverThumbStatus::isMarkedFailed()  stat 2 (marker file)
  Epub(bookPath, ...) constructor     stat 3 (legacy-cache migration)
  Storage.exists(resolved)            stat 4 (the actual answer)

The constructor stat is the non-obvious one -- Epub::Epub runs
migrateLegacyCachePath, which stats the card.

SD path lookups are the expensive primitive on this stack, and v316's
shelfCoversLoaded gate means this pass runs once per boot: on the first Home
paint, the one the user waits on. Probe the resolved thumbnail directly via
a new SD-free Epub::thumbBmpPathForDimensions(), and fall through to the
unchanged sequence on a miss, so generation behaviour -- including the
migration the constructor performs -- is untouched.

EPUB only. Xtc::getThumbBmpPath() emits a HEIGHT-only template, which sends
UITheme::getCoverThumbPath down a legacy-fallback branch that stats SD
itself; not reproducible without duplicating that logic, and XTC is rare
enough not to be worth the coupling.

Measured on an X4, 43 books: the pass now reports 42-48 ms with all four
visible cells answered by the single-stat probe.
The shelf cell BMPs are already stored at exactly cell size, so the cost of
painting them was never scaling -- it was the SD open plus a Bitmap decode
through the renderer's LRU image cache, which refuses to allocate when
maxAlloc is low. On a cold boot the library walk fragments the heap just
before the shelf paints, so the cache refused and every cell fell back to a
direct streamed decode. That is why the shelf stage measured 1563 ms on a
cold boot against 306 ms on a silent restart: it was tracking heap, not
cover count.

Reuse the existing CoverTiles mechanism under a new kRoleShelfCell. A .tile
read needs no decode and no cache budget, which removes the heap coupling
rather than just making it cheaper. Its own role rather than reusing
kRoleCenterThumb because the shelf aspect-FILLs with crop where the centre
thumb aspect-FITs -- same header dimensions, different pixels. Perspective
params are unused and passed as 0, and the format is unchanged, so no
version bump (the header comment already anticipated new roles).

Same three-step the carousel centre thumb uses: load, else render into a
packed 2bpp buffer via renderCachedBitmapToPacked2bpp /
renderBitmapToPacked2bpp, blit, and save. One reusable 3750-byte buffer for
the whole cell run, allocated nothrow -- a null buffer just means every cell
takes the old decode path. Tiles live beside their .bmp inside the book's
cache directory, so an existing cache clear removes them too.

Also switch CoverTiles::loadTile from openFileForRead to Storage.open(). A
missing tile is the normal "not baked yet" outcome, but openFileForRead
routes through the SDK's SDCardManager, which unconditionally Serial.printf()s
"File does not exist" with no log level -- four lines per shelf paint before
the tiles existed, and forever for a book with no cover art. open() is the
same read path without the narration and costs no extra SD lookup.

Measured on an X4, 43 books: shelf stage 285-306 ms -> 244-264 ms once
baked, and the first paint that bakes four tiles costs ~272 ms extra, once.
SettingInfo measures ~188 bytes (three enum vectors, four std::function
slots, a children vector), so the settings list is a chunk of heap in its
own right and it is built on the path whose peak makes onEnter decide to
silent-restart.

Three things, in descending order of how much they cost:

getSettingsListBase() reserved 64 but adds 69 entries, so the vector grew to
capacity 128. It is a function-local static that lives for the whole
process, meaning ~59 unused slots -- about 13 KB -- sat resident forever on
a device with ~380 KB. Reserve 72, with a shrink_to_fit drift guard so
outgrowing it later costs one transient reallocation instead of silently
stranding the difference again.

getSettingsList() copy-constructed from the base list, which allocates
capacity exactly equal to the source, then always appended two font-fallback
rows that are not in the base list. The first append therefore reallocated
the whole vector, briefly holding the old ~69-element buffer and a new
~138-element one. Reserve up front so it is a single allocation.

Reserve all 18 submenu vectors in rebuildSettingsLists. An unreserved push
loop of 7 rows allocates at 1, 2, 4 and 8 elements and frees the first
three; ~20 of these are built back to back, and onEnter's pre-flight refuses
on maxAlloc as well as free bytes, so the freed odd-sized blocks matter as
much as the total.

Add a static_assert that SettingInfo stays nothrow-move-constructible.
std::vector copies rather than moves when growing if the move constructor
can throw, and a copy here deep-copies the enum vectors and std::functions.
std::function's move is only noexcept from C++20, so this is load-bearing --
without it a future member could silently turn every push_back into a heap
spike.

Measured on an X4: rebuild peak -19196 -> ~-16600 bytes, free at Settings
entry 73452 -> ~77000, and maxAlloc at entry 31732 -> 51188, i.e. clearing
the 30 KB pre-flight floor by 21 KB instead of 1.7 KB.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant