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 117 additions and 6 deletions

View file

@ -112,9 +112,25 @@ 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);
}
// Materialise the owning key (the probe above borrowed utf8) only now,
// on the miss path, for the emplace.
ShapedRunKey key{&font, pxSize, color, std::string(utf8)};
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;
@ -139,7 +155,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(ShapedRunViewKey{&font, pxSize, color, utf8})
!= shapedRuns_.end();
}

View file

@ -325,6 +325,17 @@ export namespace Crafter {
// reallocated at the same address would otherwise alias stale runs.
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 FontAtlasSamplerSlot() const noexcept { return fontAtlasSamplerSlot_; }
@ -400,12 +411,25 @@ export namespace Crafter {
struct ShapedRun {
std::vector<GlyphItem> glyphs; // origin-relative
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
// would grow this without bound; on overflow we drop everything and
// rebuild (correctness is unaffected, only the hit rate after a flush).
// would grow this without bound; on overflow we evict the single
// 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;
std::unordered_map<ShapedRunKey, ShapedRun, ShapedRunKeyHash, ShapedRunKeyEqual> 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 fontAtlasImageSlot_;

View file

@ -225,6 +225,63 @@ int main() {
bufBig.data(), bufBig.size(), &advBig);
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.");
else std::println("\n{} ShapeText cache check(s) FAILED.", failures);
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;