Merge pull request 'perf(ui): LRU-evict the shaped-run cache instead of clearing it (#123)' (#143) from claude/issue-123 into master
This commit is contained in:
commit
80a69025eb
3 changed files with 117 additions and 6 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_;
|
||||
|
|
|
|||
|
|
@ -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