From c8495f548b330ff91657c7bc9e2113078011a161 Mon Sep 17 00:00:00 2001 From: catbot Date: Thu, 18 Jun 2026 13:19:11 +0000 Subject: [PATCH] perf(ui): transparent shaped-run cache lookup, no string copy on hit (#122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShapeText built ShapedRunKey{..., std::string(utf8)} before find() unconditionally — even on cache hits, on the per-frame onBuild path. N labels meant N string copies + hashes (+ frees for non-SSO strings) every frame, partially defeating the run cache. Add is_transparent hash + equality functors on shapedRuns_ and probe with a borrowing ShapedRunViewKey {const Font*, float, array, string_view}. The owning std::string is now materialised only on a miss, for emplace. Cache hits copy and hash no string. Co-Authored-By: Claude Opus 4.8 --- .../Crafter.Graphics-UI-Shared.cpp | 8 ++- interfaces/Crafter.Graphics-UI.cppm | 52 ++++++++++++++++--- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/implementations/Crafter.Graphics-UI-Shared.cpp b/implementations/Crafter.Graphics-UI-Shared.cpp index c3dee39..5c7fcf7 100644 --- a/implementations/Crafter.Graphics-UI-Shared.cpp +++ b/implementations/Crafter.Graphics-UI-Shared.cpp @@ -75,8 +75,11 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize, // the pen starting at x=0, baseline=0 so a cache hit only needs the // translate below. The whole string is shaped (no outCapacity limit) so // a later call with a larger buffer still gets the full run. - ShapedRunKey key{&font, pxSize, color, std::string(utf8)}; - auto it = shapedRuns_.find(key); + // Probe with a borrowing view key (text is a string_view onto utf8) so a + // cache hit copies and hashes no string. The owning std::string is only + // built on a miss, below, for the emplace. + ShapedRunViewKey viewKey{&font, pxSize, color, utf8}; + auto it = shapedRuns_.find(viewKey); if (it == shapedRuns_.end()) { // Miss: shape from scratch. Ensure() each glyph so brand-new ones // rasterise into the atlas and mark it dirty for the next upload — @@ -110,6 +113,7 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize, run.advance = cursor; if (shapedRuns_.size() >= kMaxShapedRuns) shapedRuns_.clear(); + ShapedRunKey key{&font, pxSize, color, std::string(utf8)}; it = shapedRuns_.emplace(std::move(key), std::move(run)).first; } diff --git a/interfaces/Crafter.Graphics-UI.cppm b/interfaces/Crafter.Graphics-UI.cppm index 0eec8f2..a47d71f 100644 --- a/interfaces/Crafter.Graphics-UI.cppm +++ b/interfaces/Crafter.Graphics-UI.cppm @@ -339,25 +339,63 @@ export namespace Crafter { // only needs a translate by (x + alignShift, baselineY). The full // string is part of the key (compared on lookup) so hash collisions // can't return the wrong run. + // + // The map owns its key string, but lookups happen every frame on the + // onBuild path. To avoid copying the string into a key on each cache + // hit, the hash and equality functors are transparent (is_transparent) + // and accept a borrowing view key whose text is a std::string_view; + // the owning std::string is only materialised on a miss, for emplace. struct ShapedRunKey { const Font* font; float pxSize; std::array color; std::string text; - bool operator==(const ShapedRunKey&) const = default; + }; + struct ShapedRunViewKey { + const Font* font; + float pxSize; + std::array color; + std::string_view text; }; struct ShapedRunKeyHash { - std::size_t operator()(const ShapedRunKey& k) const noexcept { - std::size_t h = std::hash{}(k.font); + using is_transparent = void; + static std::size_t Hash(const Font* font, float pxSize, + const std::array& color, + std::string_view text) noexcept { + std::size_t h = std::hash{}(font); auto mix = [&h](std::size_t v) noexcept { h ^= v + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2); }; - mix(std::hash{}(std::bit_cast(k.pxSize))); - for (float c : k.color) + mix(std::hash{}(std::bit_cast(pxSize))); + for (float c : color) mix(std::hash{}(std::bit_cast(c))); - mix(std::hash{}(k.text)); + mix(std::hash{}(text)); return h; } + std::size_t operator()(const ShapedRunKey& k) const noexcept { + return Hash(k.font, k.pxSize, k.color, k.text); + } + std::size_t operator()(const ShapedRunViewKey& k) const noexcept { + return Hash(k.font, k.pxSize, k.color, k.text); + } + }; + struct ShapedRunKeyEqual { + using is_transparent = void; + static bool Eq(const Font* fa, float pa, const std::array& ca, + std::string_view ta, + const Font* fb, float pb, const std::array& cb, + std::string_view tb) noexcept { + return fa == fb && pa == pb && ca == cb && ta == tb; + } + bool operator()(const ShapedRunKey& a, const ShapedRunKey& b) const noexcept { + return Eq(a.font, a.pxSize, a.color, a.text, b.font, b.pxSize, b.color, b.text); + } + bool operator()(const ShapedRunViewKey& a, const ShapedRunKey& b) const noexcept { + return Eq(a.font, a.pxSize, a.color, a.text, b.font, b.pxSize, b.color, b.text); + } + bool operator()(const ShapedRunKey& a, const ShapedRunViewKey& b) const noexcept { + return Eq(a.font, a.pxSize, a.color, a.text, b.font, b.pxSize, b.color, b.text); + } }; struct ShapedRun { std::vector glyphs; // origin-relative @@ -367,7 +405,7 @@ export namespace Crafter { // would grow this without bound; on overflow we drop everything and // rebuild (correctness is unaffected, only the hit rate after a flush). static constexpr std::size_t kMaxShapedRuns = 8192; - std::unordered_map shapedRuns_; + std::unordered_map shapedRuns_; ImageSlot outImageSlot_; ImageSlot fontAtlasImageSlot_;