perf(ui): LRU-evict the shaped-run cache instead of clearing it (#123) #143
3 changed files with 115 additions and 6 deletions
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>
commit
1d7d9f7b8e
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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_;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue