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

Merged
catbot merged 2 commits from claude/issue-123 into master 2026-06-18 15:30:08 +02:00
3 changed files with 115 additions and 6 deletions
Showing only changes of commit 1d7d9f7b8e - Show all commits

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>
catbot 2026-06-18 13:23:34 +00:00

View file

@ -109,8 +109,22 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
} }
run.advance = cursor; 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 = 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; 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 { void UIRenderer::InvalidateFont(const Font& font) noexcept {
std::erase_if(shapedRuns_, [&font](const auto& kv) { // Drop matching runs from both the map and the LRU order list, keeping the
return kv.first.font == &font; // 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();
} }

View file

@ -325,6 +325,17 @@ export namespace Crafter {
// reallocated at the same address would otherwise alias stale runs. // reallocated at the same address would otherwise alias stale runs.
void InvalidateFont(const Font& font) noexcept; void InvalidateFont(const Font& font) noexcept;
// Shaped-run cache introspection (issue #123). Hits/misses are
// byte-identical and a re-shape of an already-rasterised string leaves
// the atlas clean, so the LRU eviction policy isn't observable through
// output alone — these let tests assert that the cache stays bounded
// and that the hot set survives an overflow. IsShapedRunCached is a
// pure query: it does not bump recency.
std::size_t ShapedRunCacheSize() const noexcept { return shapedRuns_.size(); }
bool IsShapedRunCached(const Font& font, float pxSize,
std::array<float,4> color,
std::string_view utf8) const;
std::uint16_t FontAtlasImageSlot() const noexcept { return fontAtlasImageSlot_; } std::uint16_t FontAtlasImageSlot() const noexcept { return fontAtlasImageSlot_; }
std::uint16_t FontAtlasSamplerSlot() const noexcept { return fontAtlasSamplerSlot_; } std::uint16_t FontAtlasSamplerSlot() const noexcept { return fontAtlasSamplerSlot_; }
@ -362,12 +373,25 @@ export namespace Crafter {
struct ShapedRun { struct ShapedRun {
std::vector<GlyphItem> glyphs; // origin-relative std::vector<GlyphItem> glyphs; // origin-relative
float advance = 0; float advance = 0;
// Position of this run's key in shapedRunsLru_ (front = most
// recently used). Lets a hit splice the entry to the front and an
// overflow pop the least-recently-used entry, both O(1).
std::list<const ShapedRunKey*>::iterator lruIt{};
}; };
// Soft cap. A pathological caller drawing a fresh string every frame // Soft cap. A pathological caller drawing a fresh string every frame
// would grow this without bound; on overflow we drop everything and // would grow this without bound; on overflow we evict the single
// rebuild (correctness is unaffected, only the hit rate after a flush). // least-recently-used run (issue #123) rather than clearing the whole
// cache. The hot set of stable labels — reshaped every frame, so always
// near the front — stays resident, while a churn of unique strings
// (FPS counters, timers) only recycles the cold tail. Correctness is
// unaffected either way; this just avoids the periodic full-UI reshape
// spike the old clear() caused.
static constexpr std::size_t kMaxShapedRuns = 8192; static constexpr std::size_t kMaxShapedRuns = 8192;
std::unordered_map<ShapedRunKey, ShapedRun, ShapedRunKeyHash> shapedRuns_; std::unordered_map<ShapedRunKey, ShapedRun, ShapedRunKeyHash> shapedRuns_;
// LRU recency order for shapedRuns_; front = most recently used. Holds
// pointers to the keys owned by the map — stable across rehash because
// unordered_map is node-based — so eviction never re-hashes the world.
std::list<const ShapedRunKey*> shapedRunsLru_;
ImageSlot outImageSlot_; ImageSlot outImageSlot_;
ImageSlot fontAtlasImageSlot_; ImageSlot fontAtlasImageSlot_;

View file

@ -225,6 +225,63 @@ int main() {
bufBig.data(), bufBig.size(), &advBig); bufBig.data(), bufBig.size(), &advBig);
Check(advBig > advHit * 1.5f, "a larger pxSize produces a wider advance"); Check(advBig > advHit * 1.5f, "a larger pxSize produces a wider advance");
// ── 8. Overflow evicts the least-recently-used run, not the whole cache. ─
// (Issue #123) The old policy did a full clear() at the cap, periodically
// nuking the stable labels and forcing a full-UI reshape the next frame.
// The LRU policy instead pins the cache at its cap (evict one, add one) and
// keeps the hot, reshaped-every-frame set resident. A hit/miss and a
// re-shape of already-rasterised glyphs are not observable through the
// output or atlas.dirty, so this is asserted via the cache introspection
// hooks added for the issue.
ui.InvalidateFont(font);
Check(ui.ShapedRunCacheSize() == 0, "cache empty after InvalidateFont");
std::array<GlyphItem, 8> sink{};
auto shapeUnique = [&](std::size_t n) {
// Small fixed glyph set (digits + "run#"), so the atlas rasterises once
// and every call is still a distinct cache key.
std::string s = std::format("run#{}", n);
ui.ShapeText(font, kSize, 0, 0, s, white, sink.data(), sink.size(), nullptr);
};
// Insert distinct keys until the size stops growing on a brand-new insert.
// Under evict-one LRU that plateau IS the cap (evict one, add one → size
// unchanged). Under the old clear()-all policy a new insert at the cap
// drops the size to 1, so it would never plateau and `cap` would stay 0.
std::size_t cap = 0, prev = 0;
for (std::size_t i = 0; i < 100000 && cap == 0; ++i) {
shapeUnique(i);
std::size_t now = ui.ShapedRunCacheSize();
if (now == prev) cap = now;
prev = now;
}
Check(cap > 0, "cache size plateaus at a fixed cap (evict-one LRU, not clear-all)");
// Hot, every-frame label stays resident across a churn far exceeding the
// cap, because reshaping it each iteration keeps it most-recently-used.
const std::string_view kHot = "Hot Label";
ui.ShapeText(font, kSize, 0, 0, kHot, white, sink.data(), sink.size(), nullptr);
bool hotStays = ui.IsShapedRunCached(font, kSize, white, kHot);
bool stayedAtCap = true;
for (std::size_t i = 0; i < cap * 2 + 1000 && hotStays; ++i) {
shapeUnique(1'000'000 + i); // fresh keys
ui.ShapeText(font, kSize, 0, 0, kHot, white, sink.data(), sink.size(), nullptr);
hotStays = ui.IsShapedRunCached(font, kSize, white, kHot);
stayedAtCap = stayedAtCap && ui.ShapedRunCacheSize() == cap;
}
Check(hotStays, "hot label survives a churn of unique strings past the cap");
Check(stayedAtCap, "cache stays pinned at the cap (no full clear)");
// A cold string left untouched while the cache churns past it IS evicted —
// confirms the cap is enforced by eviction, not by refusing new inserts.
const std::string_view kCold = "Cold Once";
ui.ShapeText(font, kSize, 0, 0, kCold, white, sink.data(), sink.size(), nullptr);
Check(ui.IsShapedRunCached(font, kSize, white, kCold),
"cold string cached when first shaped");
for (std::size_t i = 0; i < cap + 16; ++i) shapeUnique(2'000'000 + i); // never re-touch kCold
Check(!ui.IsShapedRunCached(font, kSize, white, kCold),
"an untouched cold string is eventually evicted (LRU tail recycled)");
if (failures == 0) std::println("\nAll ShapeText cache checks passed."); if (failures == 0) std::println("\nAll ShapeText cache checks passed.");
else std::println("\n{} ShapeText cache check(s) FAILED.", failures); else std::println("\n{} ShapeText cache check(s) FAILED.", failures);
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;