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:
parent
2783e47674
commit
1d7d9f7b8e
3 changed files with 115 additions and 6 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue