perf(ui): LRU-evict the shaped-run cache instead of clearing it (#123)

At kMaxShapedRuns (8192) UIRenderer::ShapeText did a full shapedRuns_.clear().
A stream of unique strings (FPS counters, timers) alongside many stable labels
periodically nuked every stable entry, forcing a full-UI reshape the next frame.

Track recency with an auxiliary std::list<const ShapedRunKey*> (front = most
recently used) holding pointers to the keys owned by the node-based map. A hit
splices its entry to the front; an overflow pops the least-recently-used entry
from the back and erases just that one — both O(1). The hot set of labels,
reshaped every frame, stays at the front and survives, so the churn of unique
strings only recycles the cold tail. Output is byte-identical either way.

InvalidateFont now drops matching entries from both the map and the LRU list.
Adds ShapedRunCacheSize()/IsShapedRunCached() introspection hooks (the policy
isn't observable through output or atlas.dirty) and a ShapeTextCache test that
asserts the cache plateaus at a fixed cap (evict-one, not clear-all), the hot
label survives a churn past the cap, and an untouched cold string is evicted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
catbot 2026-06-18 13:23:34 +00:00
commit 1d7d9f7b8e
3 changed files with 115 additions and 6 deletions

View file

@ -109,8 +109,22 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
}
run.advance = cursor;
if (shapedRuns_.size() >= kMaxShapedRuns) shapedRuns_.clear();
// Evict the least-recently-used run (LRU list back) on overflow rather
// than clearing the whole cache. The back is whatever has gone longest
// without a reshape — a stale unique string, never a stable label
// (those are reshaped every frame and sit at the front), so the hot set
// survives and the next frame doesn't trigger a full-UI reshape.
if (shapedRuns_.size() >= kMaxShapedRuns) {
const ShapedRunKey* victim = shapedRunsLru_.back();
shapedRunsLru_.pop_back();
shapedRuns_.erase(*victim);
}
it = shapedRuns_.emplace(std::move(key), std::move(run)).first;
it->second.lruIt = shapedRunsLru_.insert(shapedRunsLru_.begin(), &it->first);
} else {
// Hit: promote to most-recently-used. splice is O(1) and keeps lruIt
// (and every other iterator) valid.
shapedRunsLru_.splice(shapedRunsLru_.begin(), shapedRunsLru_, it->second.lruIt);
}
const ShapedRun& run = it->second;
@ -135,7 +149,21 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
}
void UIRenderer::InvalidateFont(const Font& font) noexcept {
std::erase_if(shapedRuns_, [&font](const auto& kv) {
return kv.first.font == &font;
});
// Drop matching runs from both the map and the LRU order list, keeping the
// two in sync (the list points at keys owned by the map).
for (auto it = shapedRuns_.begin(); it != shapedRuns_.end(); ) {
if (it->first.font == &font) {
shapedRunsLru_.erase(it->second.lruIt);
it = shapedRuns_.erase(it);
} else {
++it;
}
}
}
bool UIRenderer::IsShapedRunCached(const Font& font, float pxSize,
std::array<float,4> color,
std::string_view utf8) const {
return shapedRuns_.find(ShapedRunKey{&font, pxSize, color, std::string(utf8)})
!= shapedRuns_.end();
}