Add render cache for SVG icons#36863
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an in-memory render cache for SVG icon HTML in modules/svg, targeting reduced allocations/latency when the same icon is rendered repeatedly with non-default size/class under concurrency.
Changes:
- Introduces a global
sync.Mapcache for renderedtemplate.HTMLfor(icon,size,class)combinations. - Bypasses the cache for default parameters to keep the fast path as a direct map lookup.
- Clears the render cache during
svg.Init()to avoid stale entries across re-initialization.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
84cd728 to
7ca540a
Compare
Cache the final rendered template.HTML output for SVG icons that use non-default size or class parameters using sync.Map. Icons rendered with default parameters bypass the cache and use a direct read-only map lookup. Co-Authored-By: Claude (Opus 4.6) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
Use the helper function in MockIcon restore and test setup instead of manually clearing the map, ensuring cache size counter is also reset. Co-Authored-By: Claude (Opus 4.6) <noreply@anthropic.com>
Remove redundant output content assertions, focus on cache behavior. Co-Authored-By: Claude (Opus 4.6) <noreply@anthropic.com>
Signed-off-by: silverwind <me@silverwind.io>
Signed-off-by: silverwind <me@silverwind.io>
The cache key space is naturally bounded by the finite set of hardcoded template calls. No user input can reach the size or class parameters, so unbounded growth is impossible in practice. Co-Authored-By: Claude (Opus 4.6) <noreply@anthropic.com>
Init() only runs once per process and MockIcon() replaces the svgIcons map entry which produces different cache keys, so clearing is unnecessary. Co-Authored-By: Claude (Opus 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude (Opus 4.6) <noreply@anthropic.com>
|
Removed all cache clears. The code paths install/non-install page never happen in the same run, so we never need to clear this cache. |
What kind of Mutex did you use? I don't think RWMutex can be the problem. |
Why 300ms? If I understand correctly, your tests mean that "1000 SVGs take 0.356ms", right? |
|
Right, microseconds, not milliseconds :) |
|
I beg you to understand the problem and use facts to discuss. |
|
I will benchmark mutex vs. no-mutex in a bit. Scenario will be concurrency 16, 200 SVGs with 50 variations. That should give representative results. |
|
Written by Claude. Benchmark results (Apple M3 Max,
RWMutex + function call overhead adds ~128ns at 16 goroutines. The cache is still a net win (~131 vs ~225 ns), but the mutex is clearly the bottleneck in the cached path. Benchmark code (svg_test.go)package svg
import (
"fmt"
"html/template"
"testing"
gitea_html "code.gitea.io/gitea/modules/htmlutil"
)
type benchmarkCall struct {
icon string
size int
class string
}
func setupBenchmark(numIcons, numVariations int) []benchmarkCall {
svgIcons = make(map[string]svgIconItem, numIcons)
for i := range numIcons {
name := fmt.Sprintf("icon-%d", i)
svgIcons[name] = svgIconItem{
html: fmt.Sprintf(`<svg class="svg %s" width="16" height="16"><path d="M0 0h16v16H0z"/></svg>`, name),
}
}
svgRenderedCache = make(map[svgCacheKey]template.HTML)
calls := make([]benchmarkCall, numIcons*numVariations)
for i := range numIcons {
for v := range numVariations {
calls[i*numVariations+v] = benchmarkCall{
icon: fmt.Sprintf("icon-%d", i),
size: 16 + v,
class: "extra-class",
}
}
}
return calls
}
// BenchmarkRenderHTML_CachedNoMutex benchmarks the same path as Cached but without RWMutex.
func BenchmarkRenderHTML_CachedNoMutex(b *testing.B) {
calls := setupBenchmark(200, 50)
for _, c := range calls {
RenderHTML(c.icon, c.size, c.class)
}
numCalls := len(calls)
b.SetParallelism(16)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
c := &calls[i%numCalls]
size, class := gitea_html.ParseSizeAndClass(defaultSize, "", c.size, c.class)
if svgItem, ok := svgIcons[c.icon]; ok {
if size != defaultSize || class != "" {
cacheKey := svgCacheKey{c.icon, size, class}
if cachedHTML, cached := svgRenderedCache[cacheKey]; cached && !svgItem.mocking {
_ = cachedHTML
}
}
}
i++
}
})
}
// BenchmarkRenderHTML_Cached benchmarks the full renderHTML with cache hits.
func BenchmarkRenderHTML_Cached(b *testing.B) {
calls := setupBenchmark(200, 50)
for _, c := range calls {
RenderHTML(c.icon, c.size, c.class)
}
numCalls := len(calls)
b.SetParallelism(16)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
c := &calls[i%numCalls]
RenderHTML(c.icon, c.size, c.class)
i++
}
})
}
// BenchmarkRenderHTML_Uncached benchmarks renderHTML without cache hits (unique keys every time).
func BenchmarkRenderHTML_Uncached(b *testing.B) {
calls := setupBenchmark(200, 50)
numCalls := len(calls)
b.SetParallelism(16)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
c := &calls[i%numCalls]
RenderHTML(c.icon, 1000+i, c.class)
i++
}
})
}
// BenchmarkRenderHTML_DefaultSize benchmarks the fast path (default size, no extra class, no cache).
func BenchmarkRenderHTML_DefaultSize(b *testing.B) {
calls := setupBenchmark(200, 50)
numCalls := len(calls)
b.SetParallelism(16)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
RenderHTML(calls[i%numCalls].icon)
i++
}
})
} |
|
Mutex overhead seems a bit much, not sure though. |
|
If you need the "limit", the RWMutex is still needed. Otherwise, sync.Map can't count the items. If you need to make sync.Map have item count, then it needs another "atomic", then the performance degrades to the same level as OK, maybe can use count with a write-only mutex. |
|
|
lgtm |
|
Written by Claude. The ~20x speedup (130 ns → 6.9 ns) comes from eliminating CPU cache line invalidation on the read path:
|
|
OK, finally improved the render time of a page with 1000 SVGs about 300us (0.3ms), for most normal pages, less than 0.1ms |
* giteaofficial/main: Update minimum go version to 1.26.1, golangci-lint to 2.11.2, fix test style (go-gitea#36876) Add render cache for SVG icons (go-gitea#36863) Fix incorrect viewed files counter if reverted change was viewed (go-gitea#36819) [skip ci] Updated translations via Crowdin Clean up `refreshViewedFilesSummary` (go-gitea#36868) Remove `util.URLJoin` and replace all callers with direct path concatenation (go-gitea#36867) Optimize Docker build with dependency layer caching (go-gitea#36864) Fix URLJoin, markup render link reoslving, sign-in/up/linkaccount page common data (go-gitea#36861) Fix CodeQL code scanning alerts (go-gitea#36858) Refactor auth middleware (go-gitea#36848) Update Nix flake (go-gitea#36857) Update JS deps (go-gitea#36850) Load `mentionValues` asynchronously (go-gitea#36739) [skip ci] Updated translations via Crowdin Fix dbfs error handling (go-gitea#36844) Fix OAuth2 authorization code expiry and reuse handling (go-gitea#36797) Fix org permission API visibility checks for hidden members and private orgs (go-gitea#36798)
Cache the final rendered
template.HTMLoutput for SVG icons that use non-default size or class parameters usingsync.Map. Icons rendered with default parameters bypass the cache and use a direct read-only map lookup.Benchmark results for rendering 1000 varied SVG icons under high concurrency (16 goroutines):