From ee9d36dd4ff36d214355d9fde241a768fac57b88 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Fri, 17 Jul 2026 02:11:26 +0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20perf:=20make=20render-cache=20co?= =?UTF-8?q?mpression=20opt-in,=20default=20off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 ++ internal/config/config.go | 2 ++ internal/render/cache.go | 17 +++++++--- internal/render/render_bench_test.go | 50 ++++++++++++++++------------ internal/render/render_test.go | 30 +++++++++-------- internal/sshserver/server.go | 2 +- 6 files changed, 63 insertions(+), 41 deletions(-) diff --git a/.env.example b/.env.example index 891e7f5..ef12687 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,9 @@ MAX_TERMINAL_CELLS=256000 # Memory budget in MB for the rendered-frame cache (0 disables caching). RENDER_CACHE_MB=256 +# Compress cached frames (flate). Cuts cache RAM ~2x but decompresses on every +# cache hit, so CPU rises with concurrent sessions. Off = zero-copy reads. +RENDER_CACHE_COMPRESS=false # Go soft memory limit; set below your container limit to avoid OOM kills. GOMEMLIMIT=1GiB diff --git a/internal/config/config.go b/internal/config/config.go index 7629b54..e1a4be2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,6 +33,7 @@ type Config struct { MaxTerminalCells int SessionTimeout time.Duration RenderCacheMB int + RenderCacheCompress bool BrightnessThreshold int Charset string Invert bool @@ -133,6 +134,7 @@ func Load() Config { MaxTerminalCells: envInt("MAX_TERMINAL_CELLS", 500*512, 1, maxInt), SessionTimeout: envDurationMs("SESSION_TIMEOUT", 10*time.Minute), RenderCacheMB: envInt("RENDER_CACHE_MB", 256, 0, maxInt), + RenderCacheCompress: envBool("RENDER_CACHE_COMPRESS", false), BrightnessThreshold: envInt("BRIGHTNESS_THRESHOLD", 40, 0, 100), Charset: envString("CHARSET", "detailed"), Invert: envBool("INVERT", false), diff --git a/internal/render/cache.go b/internal/render/cache.go index e030b39..bdf8760 100644 --- a/internal/render/cache.go +++ b/internal/render/cache.go @@ -29,6 +29,7 @@ type cacheShard struct { type Cache struct { shards []cacheShard + compress bool size atomic.Int64 hits atomic.Uint64 misses atomic.Uint64 @@ -83,12 +84,12 @@ func decompressAscii(src []byte, origLen int) []byte { return buf.Bytes() } -func NewCache(maxBytes int64) *Cache { +func NewCache(maxBytes int64, compress bool) *Cache { if maxBytes <= 0 { return nil } shardCount := int(min(int64(16), max(int64(1), maxBytes/(1<<20)))) - cache := &Cache{shards: make([]cacheShard, shardCount)} + cache := &Cache{shards: make([]cacheShard, shardCount), compress: compress} for i := range cache.shards { cache.shards[i] = cacheShard{ maxBytes: maxBytes / int64(shardCount), @@ -130,6 +131,9 @@ func (c *Cache) get(key cacheKey) ([]byte, bool) { data, origLen := entry.data, entry.origLen shard.mu.Unlock() c.hits.Add(1) + if !c.compress { + return data, true + } return decompressAscii(data, origLen), true } @@ -138,8 +142,11 @@ func (c *Cache) put(key cacheKey, ascii []byte) { return } shard := c.shard(key) - compressed := compressAscii(ascii) - cost := entryCost(key, compressed) + data, origLen := ascii, 0 + if c.compress { + data, origLen = compressAscii(ascii), len(ascii) + } + cost := entryCost(key, data) if cost > shard.maxBytes { c.rejections.Add(1) return @@ -149,7 +156,7 @@ func (c *Cache) put(key cacheKey, ascii []byte) { if _, ok := shard.entries[key]; ok { return } - entry := &cacheEntry{key: key, data: compressed, origLen: len(ascii), cost: cost} + entry := &cacheEntry{key: key, data: data, origLen: origLen, cost: cost} shard.entries[key] = shard.order.PushBack(entry) shard.size += cost c.size.Add(cost) diff --git a/internal/render/render_bench_test.go b/internal/render/render_bench_test.go index cdfa5da..2287b61 100644 --- a/internal/render/render_bench_test.go +++ b/internal/render/render_bench_test.go @@ -43,27 +43,35 @@ func BenchmarkRenderGray(b *testing.B) { benchRender(b, ColorTierNone, 120, func BenchmarkRenderTrueBig(b *testing.B) { benchRender(b, ColorTierTrueColor, 240, 70) } func BenchmarkRenderCachedParallel(b *testing.B) { - fc := loadBenchSet(b) - r := NewRenderer(0, fc.ColorFrames, Options{ - BrightnessThreshold: 40, - Charset: "detailed", - }, NewCache(8<<20)) - frame, err := r.Render(0, 120, 40, false, ColorTierTrueColor) - if err != nil { - b.Fatal(err) - } - b.SetBytes(int64(len(frame))) - b.ReportAllocs() - var failures atomic.Uint64 - b.ResetTimer() - b.RunParallel(func(pb *testing.PB) { - for pb.Next() { - if _, err := r.Render(0, 120, 40, false, ColorTierTrueColor); err != nil { - failures.Add(1) - } + for _, compress := range []bool{false, true} { + name := "uncompressed" + if compress { + name = "compressed" } - }) - if failures.Load() != 0 { - b.Fatalf("render failures: %d", failures.Load()) + b.Run(name, func(b *testing.B) { + fc := loadBenchSet(b) + r := NewRenderer(0, fc.ColorFrames, Options{ + BrightnessThreshold: 40, + Charset: "detailed", + }, NewCache(8<<20, compress)) + frame, err := r.Render(0, 120, 40, false, ColorTierTrueColor) + if err != nil { + b.Fatal(err) + } + b.SetBytes(int64(len(frame))) + b.ReportAllocs() + var failures atomic.Uint64 + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if _, err := r.Render(0, 120, 40, false, ColorTierTrueColor); err != nil { + failures.Add(1) + } + } + }) + if failures.Load() != 0 { + b.Fatalf("render failures: %d", failures.Load()) + } + }) } } diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 890fdf8..040543f 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -64,7 +64,7 @@ func TestRenderConcurrentSameKey(t *testing.T) { r := NewRenderer(0, [][]byte{jpegBuf.Bytes()}, Options{ BrightnessThreshold: 40, Charset: "standard", - }, NewCache(1<<20)) + }, NewCache(1<<20, false)) var wg sync.WaitGroup results := make([][]byte, 32) @@ -104,7 +104,7 @@ func TestRenderCacheEvictsByBytes(t *testing.T) { key := func(index int) cacheKey { return cacheKey{index: index} } payload := incompressible(1000) budget := 3 * entryCost(key(0), compressAscii(payload)) - c := NewCache(budget) + c := NewCache(budget, true) for i := range 5 { c.put(key(i), payload) } @@ -120,20 +120,22 @@ func TestRenderCacheEvictsByBytes(t *testing.T) { } func TestRenderCacheRoundTrips(t *testing.T) { - c := NewCache(1 << 20) - want := incompressible(4096) - c.put(cacheKey{}, want) - got, ok := c.get(cacheKey{}) - if !ok { - t.Fatal("entry should be cached") - } - if !bytes.Equal(got, want) { - t.Fatal("decompressed entry does not match original") + for _, compress := range []bool{false, true} { + c := NewCache(1<<20, compress) + want := incompressible(4096) + c.put(cacheKey{}, want) + got, ok := c.get(cacheKey{}) + if !ok { + t.Fatalf("compress=%v: entry should be cached", compress) + } + if !bytes.Equal(got, want) { + t.Fatalf("compress=%v: entry does not match original", compress) + } } } func TestRenderCacheDisabled(t *testing.T) { - c := NewCache(0) + c := NewCache(0, false) if c != nil { t.Fatal("zero budget should disable the cache") } @@ -144,7 +146,7 @@ func TestRenderCacheDisabled(t *testing.T) { } func TestRenderCacheRejectsOversizedEntry(t *testing.T) { - c := NewCache(256) + c := NewCache(256, true) c.put(cacheKey{}, incompressible(10_000)) if _, ok := c.get(cacheKey{}); ok { t.Error("entry larger than budget should not be cached") @@ -155,7 +157,7 @@ func TestRenderCacheRejectsOversizedEntry(t *testing.T) { } func TestRenderCacheAccountsCompressedSize(t *testing.T) { - cache := NewCache(512) + cache := NewCache(512, true) value := make([]byte, 1, 4096) cache.put(cacheKey{}, value) if _, ok := cache.get(cacheKey{}); !ok { diff --git a/internal/sshserver/server.go b/internal/sshserver/server.go index 7ec67ab..386bb7b 100644 --- a/internal/sshserver/server.go +++ b/internal/sshserver/server.go @@ -232,7 +232,7 @@ func clampTermSize(cols, rows, maxDimension, maxCells, quantum int) (int, int) { func New(deps ServerDeps) *Server { cfg := deps.Config - cache := render.NewCache(int64(cfg.RenderCacheMB) << 20) + cache := render.NewCache(int64(cfg.RenderCacheMB)<<20, cfg.RenderCacheCompress) sets := make([]frameSet, len(deps.VideoSets)) for i, data := range deps.VideoSets { sets[i] = frameSet{