3 Commits
10 changed files with 196 additions and 55 deletions
+3
View File
@@ -31,6 +31,9 @@ MAX_TERMINAL_CELLS=256000
# Memory budget in MB for the rendered-frame cache (0 disables caching). # Memory budget in MB for the rendered-frame cache (0 disables caching).
RENDER_CACHE_MB=256 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. # Go soft memory limit; set below your container limit to avoid OOM kills.
GOMEMLIMIT=1GiB GOMEMLIMIT=1GiB
+2
View File
@@ -33,6 +33,7 @@ type Config struct {
MaxTerminalCells int MaxTerminalCells int
SessionTimeout time.Duration SessionTimeout time.Duration
RenderCacheMB int RenderCacheMB int
RenderCacheCompress bool
BrightnessThreshold int BrightnessThreshold int
Charset string Charset string
Invert bool Invert bool
@@ -133,6 +134,7 @@ func Load() Config {
MaxTerminalCells: envInt("MAX_TERMINAL_CELLS", 500*512, 1, maxInt), MaxTerminalCells: envInt("MAX_TERMINAL_CELLS", 500*512, 1, maxInt),
SessionTimeout: envDurationMs("SESSION_TIMEOUT", 10*time.Minute), SessionTimeout: envDurationMs("SESSION_TIMEOUT", 10*time.Minute),
RenderCacheMB: envInt("RENDER_CACHE_MB", 256, 0, maxInt), RenderCacheMB: envInt("RENDER_CACHE_MB", 256, 0, maxInt),
RenderCacheCompress: envBool("RENDER_CACHE_COMPRESS", false),
BrightnessThreshold: envInt("BRIGHTNESS_THRESHOLD", 40, 0, 100), BrightnessThreshold: envInt("BRIGHTNESS_THRESHOLD", 40, 0, 100),
Charset: envString("CHARSET", "detailed"), Charset: envString("CHARSET", "detailed"),
Invert: envBool("INVERT", false), Invert: envBool("INVERT", false),
+59 -13
View File
@@ -2,7 +2,9 @@ package render
import ( import (
"bytes" "bytes"
"compress/flate"
"container/list" "container/list"
"io"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -27,6 +29,7 @@ type cacheShard struct {
type Cache struct { type Cache struct {
shards []cacheShard shards []cacheShard
compress bool
size atomic.Int64 size atomic.Int64
hits atomic.Uint64 hits atomic.Uint64
misses atomic.Uint64 misses atomic.Uint64
@@ -38,20 +41,55 @@ type Cache struct {
type cacheEntry struct { type cacheEntry struct {
key cacheKey key cacheKey
ascii []byte data []byte
origLen int
cost int64 cost int64
} }
func entryCost(_ cacheKey, ascii []byte) int64 { func entryCost(_ cacheKey, data []byte) int64 {
return int64(cap(ascii)) + 160 return int64(cap(data)) + 160
} }
func NewCache(maxBytes int64) *Cache { var flateWriters = sync.Pool{New: func() any {
w, _ := flate.NewWriter(io.Discard, 1)
return w
}}
type flateReader interface {
io.Reader
flate.Resetter
}
var flateReaders = sync.Pool{New: func() any {
return flate.NewReader(bytes.NewReader(nil)).(flateReader)
}}
func compressAscii(src []byte) []byte {
var buf bytes.Buffer
buf.Grow(len(src)/3 + 64)
w := flateWriters.Get().(*flate.Writer)
w.Reset(&buf)
_, _ = w.Write(src)
_ = w.Close()
flateWriters.Put(w)
return bytes.Clone(buf.Bytes())
}
func decompressAscii(src []byte, origLen int) []byte {
r := flateReaders.Get().(flateReader)
_ = r.Reset(bytes.NewReader(src), nil)
buf := bytes.NewBuffer(make([]byte, 0, origLen))
_, _ = io.Copy(buf, r)
flateReaders.Put(r)
return buf.Bytes()
}
func NewCache(maxBytes int64, compress bool) *Cache {
if maxBytes <= 0 { if maxBytes <= 0 {
return nil return nil
} }
shardCount := int(min(int64(16), max(int64(1), maxBytes/(1<<20)))) 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 { for i := range cache.shards {
cache.shards[i] = cacheShard{ cache.shards[i] = cacheShard{
maxBytes: maxBytes / int64(shardCount), maxBytes: maxBytes / int64(shardCount),
@@ -82,15 +120,21 @@ func (c *Cache) get(key cacheKey) ([]byte, bool) {
} }
shard := c.shard(key) shard := c.shard(key)
shard.mu.Lock() shard.mu.Lock()
defer shard.mu.Unlock()
el, ok := shard.entries[key] el, ok := shard.entries[key]
if !ok { if !ok {
shard.mu.Unlock()
c.misses.Add(1) c.misses.Add(1)
return nil, false return nil, false
} }
c.hits.Add(1)
shard.order.MoveToBack(el) shard.order.MoveToBack(el)
return el.Value.(*cacheEntry).ascii, true entry := el.Value.(*cacheEntry)
data, origLen := entry.data, entry.origLen
shard.mu.Unlock()
c.hits.Add(1)
if !c.compress {
return data, true
}
return decompressAscii(data, origLen), true
} }
func (c *Cache) put(key cacheKey, ascii []byte) { func (c *Cache) put(key cacheKey, ascii []byte) {
@@ -98,7 +142,11 @@ func (c *Cache) put(key cacheKey, ascii []byte) {
return return
} }
shard := c.shard(key) shard := c.shard(key)
cost := entryCost(key, ascii) data, origLen := ascii, 0
if c.compress {
data, origLen = compressAscii(ascii), len(ascii)
}
cost := entryCost(key, data)
if cost > shard.maxBytes { if cost > shard.maxBytes {
c.rejections.Add(1) c.rejections.Add(1)
return return
@@ -108,7 +156,8 @@ func (c *Cache) put(key cacheKey, ascii []byte) {
if _, ok := shard.entries[key]; ok { if _, ok := shard.entries[key]; ok {
return return
} }
shard.entries[key] = shard.order.PushBack(&cacheEntry{key: key, ascii: ascii, cost: cost}) entry := &cacheEntry{key: key, data: data, origLen: origLen, cost: cost}
shard.entries[key] = shard.order.PushBack(entry)
shard.size += cost shard.size += cost
c.size.Add(cost) c.size.Add(cost)
for shard.size > shard.maxBytes { for shard.size > shard.maxBytes {
@@ -219,9 +268,6 @@ func (r *Renderer) Render(index, width, height int, keepAspectRatio bool, tier C
} }
putPixBuf(pix) putPixBuf(pix)
if r.cache != nil && cap(ascii) > len(ascii)+len(ascii)/4 {
ascii = bytes.Clone(ascii)
}
r.cache.put(key, ascii) r.cache.put(key, ascii)
if r.cache != nil { if r.cache != nil {
r.cache.renders.Add(1) r.cache.renders.Add(1)
+9 -1
View File
@@ -43,11 +43,17 @@ func BenchmarkRenderGray(b *testing.B) { benchRender(b, ColorTierNone, 120,
func BenchmarkRenderTrueBig(b *testing.B) { benchRender(b, ColorTierTrueColor, 240, 70) } func BenchmarkRenderTrueBig(b *testing.B) { benchRender(b, ColorTierTrueColor, 240, 70) }
func BenchmarkRenderCachedParallel(b *testing.B) { func BenchmarkRenderCachedParallel(b *testing.B) {
for _, compress := range []bool{false, true} {
name := "uncompressed"
if compress {
name = "compressed"
}
b.Run(name, func(b *testing.B) {
fc := loadBenchSet(b) fc := loadBenchSet(b)
r := NewRenderer(0, fc.ColorFrames, Options{ r := NewRenderer(0, fc.ColorFrames, Options{
BrightnessThreshold: 40, BrightnessThreshold: 40,
Charset: "detailed", Charset: "detailed",
}, NewCache(8<<20)) }, NewCache(8<<20, compress))
frame, err := r.Render(0, 120, 40, false, ColorTierTrueColor) frame, err := r.Render(0, 120, 40, false, ColorTierTrueColor)
if err != nil { if err != nil {
b.Fatal(err) b.Fatal(err)
@@ -66,4 +72,6 @@ func BenchmarkRenderCachedParallel(b *testing.B) {
if failures.Load() != 0 { if failures.Load() != 0 {
b.Fatalf("render failures: %d", failures.Load()) b.Fatalf("render failures: %d", failures.Load())
} }
})
}
} }
+41 -13
View File
@@ -64,7 +64,7 @@ func TestRenderConcurrentSameKey(t *testing.T) {
r := NewRenderer(0, [][]byte{jpegBuf.Bytes()}, Options{ r := NewRenderer(0, [][]byte{jpegBuf.Bytes()}, Options{
BrightnessThreshold: 40, BrightnessThreshold: 40,
Charset: "standard", Charset: "standard",
}, NewCache(1<<20)) }, NewCache(1<<20, false))
var wg sync.WaitGroup var wg sync.WaitGroup
results := make([][]byte, 32) results := make([][]byte, 32)
@@ -88,12 +88,25 @@ func TestRenderConcurrentSameKey(t *testing.T) {
} }
} }
func incompressible(n int) []byte {
b := make([]byte, n)
x := uint32(0x9e3779b9)
for i := range b {
x ^= x << 13
x ^= x >> 17
x ^= x << 5
b[i] = byte(x)
}
return b
}
func TestRenderCacheEvictsByBytes(t *testing.T) { func TestRenderCacheEvictsByBytes(t *testing.T) {
key := func(index int) cacheKey { return cacheKey{index: index} } key := func(index int) cacheKey { return cacheKey{index: index} }
budget := 3 * entryCost(key(0), bytes.Repeat([]byte("x"), 1000)) payload := incompressible(1000)
c := NewCache(budget) budget := 3 * entryCost(key(0), compressAscii(payload))
c := NewCache(budget, true)
for i := range 5 { for i := range 5 {
c.put(key(i), bytes.Repeat([]byte("x"), 1000)) c.put(key(i), payload)
} }
if c.size.Load() > budget { if c.size.Load() > budget {
t.Errorf("size %d exceeds budget %d", c.size.Load(), budget) t.Errorf("size %d exceeds budget %d", c.size.Load(), budget)
@@ -106,8 +119,23 @@ func TestRenderCacheEvictsByBytes(t *testing.T) {
} }
} }
func TestRenderCacheRoundTrips(t *testing.T) {
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) { func TestRenderCacheDisabled(t *testing.T) {
c := NewCache(0) c := NewCache(0, false)
if c != nil { if c != nil {
t.Fatal("zero budget should disable the cache") t.Fatal("zero budget should disable the cache")
} }
@@ -118,8 +146,8 @@ func TestRenderCacheDisabled(t *testing.T) {
} }
func TestRenderCacheRejectsOversizedEntry(t *testing.T) { func TestRenderCacheRejectsOversizedEntry(t *testing.T) {
c := NewCache(256) c := NewCache(256, true)
c.put(cacheKey{}, bytes.Repeat([]byte("x"), 10_000)) c.put(cacheKey{}, incompressible(10_000))
if _, ok := c.get(cacheKey{}); ok { if _, ok := c.get(cacheKey{}); ok {
t.Error("entry larger than budget should not be cached") t.Error("entry larger than budget should not be cached")
} }
@@ -128,15 +156,15 @@ func TestRenderCacheRejectsOversizedEntry(t *testing.T) {
} }
} }
func TestRenderCacheAccountsRetainedCapacity(t *testing.T) { func TestRenderCacheAccountsCompressedSize(t *testing.T) {
cache := NewCache(512) cache := NewCache(512, true)
value := make([]byte, 1, 4096) value := make([]byte, 1, 4096)
cache.put(cacheKey{}, value) cache.put(cacheKey{}, value)
if _, ok := cache.get(cacheKey{}); ok { if _, ok := cache.get(cacheKey{}); !ok {
t.Fatal("cache accepted an entry whose backing allocation exceeds its budget") t.Fatal("small value should be cached regardless of its backing capacity")
} }
if cache.Stats().Rejections != 1 { if got := cache.size.Load(); got > 512 {
t.Fatalf("rejections = %d, want 1", cache.Stats().Rejections) t.Fatalf("size = %d, want <= 512", got)
} }
} }
+24 -5
View File
@@ -232,7 +232,7 @@ func clampTermSize(cols, rows, maxDimension, maxCells, quantum int) (int, int) {
func New(deps ServerDeps) *Server { func New(deps ServerDeps) *Server {
cfg := deps.Config 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)) sets := make([]frameSet, len(deps.VideoSets))
for i, data := range deps.VideoSets { for i, data := range deps.VideoSets {
sets[i] = frameSet{ sets[i] = frameSet{
@@ -447,17 +447,36 @@ type termSize struct {
width int width int
height int height int
updated time.Time updated time.Time
timer *time.Timer
} }
func (t *termSize) set(w, h, maxDimension, maxCells int, force bool) { func (t *termSize) set(w, h, maxDimension, maxCells int, force bool) {
width, height := clampTermSize(w, h, maxDimension, maxCells, terminalSizeQuantum)
t.mu.Lock() t.mu.Lock()
if !force && time.Since(t.updated) < resizeDebounce { defer t.mu.Unlock()
t.mu.Unlock()
if !force {
if remaining := resizeDebounce - time.Since(t.updated); remaining > 0 {
if t.timer != nil {
t.timer.Stop()
}
t.timer = time.AfterFunc(remaining, func() {
t.mu.Lock()
defer t.mu.Unlock()
t.width, t.height = width, height
t.updated = time.Now()
})
return return
} }
t.width, t.height = clampTermSize(w, h, maxDimension, maxCells, terminalSizeQuantum) }
if t.timer != nil {
t.timer.Stop()
t.timer = nil
}
t.width, t.height = width, height
t.updated = time.Now() t.updated = time.Now()
t.mu.Unlock()
} }
func (t *termSize) get() (int, int) { func (t *termSize) get() (int, int) {
+21
View File
@@ -3,6 +3,7 @@ package sshserver
import ( import (
"sync" "sync"
"testing" "testing"
"time"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@@ -102,6 +103,26 @@ func TestTermSizeDebouncesResize(t *testing.T) {
} }
} }
func TestTermSizeAppliesFinalResizeAfterDebounce(t *testing.T) {
size := &termSize{}
size.set(80, 24, 512, 500*512, true)
// Rapid burst of resize events, as happens during an interactive drag-resize.
size.set(100, 40, 512, 500*512, false)
size.set(150, 60, 512, 500*512, false)
size.set(200, 100, 512, 500*512, false)
if w, h := size.get(); w != 80 || h != 24 {
t.Fatalf("size changed before debounce elapsed: %dx%d", w, h)
}
time.Sleep(resizeDebounce + 50*time.Millisecond)
if w, h := size.get(); w != 200 || h != 100 {
t.Fatalf("final resize was not applied after debounce: got %dx%d, want 200x100", w, h)
}
}
func TestClampTermSize(t *testing.T) { func TestClampTermSize(t *testing.T) {
w, h := clampTermSize(1000, 500, 512, 65536, 4) w, h := clampTermSize(1000, 500, 512, 65536, 4)
if w < 1 || h < 1 || w > 512 || h > 512 || w*h > 65536 { if w < 1 || h < 1 || w > 512 || h > 512 || w*h > 65536 {
+3
View File
@@ -112,6 +112,9 @@ func Load(filename string) (*FramesContainer, error) {
if off != len(raw) { if off != len(raw) {
return nil, invalid() return nil, invalid()
} }
file.dropResident()
data := &FramesContainer{ColorFrames: colorFrames, FPS: fps} data := &FramesContainer{ColorFrames: colorFrames, FPS: fps}
frameFileOwners.Store(data, file) frameFileOwners.Store(data, file)
owned = true owned = true
+2
View File
@@ -8,3 +8,5 @@ func readFrameFile(filename string) (*frameFile, error) {
data, err := os.ReadFile(filename) data, err := os.ReadFile(filename)
return &frameFile{data: data}, err return &frameFile{data: data}, err
} }
func (f *frameFile) dropResident() {}
+9
View File
@@ -28,6 +28,8 @@ func readFrameFile(filename string) (*frameFile, error) {
data, err := os.ReadFile(filename) data, err := os.ReadFile(filename)
return &frameFile{data: data}, err return &frameFile{data: data}, err
} }
_ = syscall.Madvise(data, syscall.MADV_RANDOM)
return &frameFile{ return &frameFile{
data: data, data: data,
cleanup: func() error { cleanup: func() error {
@@ -35,3 +37,10 @@ func readFrameFile(filename string) (*frameFile, error) {
}, },
}, nil }, nil
} }
func (f *frameFile) dropResident() {
if f == nil || f.cleanup == nil || len(f.data) == 0 {
return
}
_ = syscall.Madvise(f.data, syscall.MADV_DONTNEED)
}