perf(core): store objects in mutex-guarded maps so recycled IDs do not allocate (#6)
- commit
- f02b08f059aa2943c856a60567e9cd8fe9d48272
- author
- bnema · · 2026-10-01 20:20 UTC
- committer
- GitHub · · 2026-10-01 20:20 UTC
- parent
- b45de7b
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.6.2
4 files · +74 -3
M alloc_test.go +15 -0
| @@ -39,4 +39,19 @@ func TestNumericPathsDoNotAllocate(t *testing.T) { | |||
| 39 | 39 | t.Fatalf("proxy received %d, want 42", p.value) | |
| 40 | 40 | } | |
| 41 | 41 | }) | |
| 42 | + | ||
| 43 | + | // A frame callback registers and retires one object per frame under a | |
| 44 | + | // recycled ID; the object tables must reuse their storage. | |
| 45 | + | t.Run("RegisterRecycledID", func(t *testing.T) { | |
| 46 | + | d := newDisplay(&chunkConn{}) | |
| 47 | + | p := &BaseProxy{id: d.AllocateID(), context: d.context} | |
| 48 | + | cycle := func() { | |
| 49 | + | d.context.Register(p) | |
| 50 | + | d.context.Unregister(p) | |
| 51 | + | } | |
| 52 | + | cycle() | |
| 53 | + | if n := testing.AllocsPerRun(100, cycle); n != 0 { | |
| 54 | + | t.Fatalf("register/unregister allocated %v times per cycle, want 0", n) | |
| 55 | + | } | |
| 56 | + | }) | |
| 42 | 57 | } | |
| @@ -39,4 +39,19 @@ func TestNumericPathsDoNotAllocate(t *testing.T) { | |||||
| 39 | t.Fatalf("proxy received %d, want 42", p.value) | 39 | t.Fatalf("proxy received %d, want 42", p.value) | ||
| 40 | } | 40 | } | ||
| 41 | }) | 41 | }) | ||
| 42 | + | ||||
| 43 | + | // A frame callback registers and retires one object per frame under a | |||
| 44 | + | // recycled ID; the object tables must reuse their storage. | |||
| 45 | + | t.Run("RegisterRecycledID", func(t *testing.T) { | |||
| 46 | + | d := newDisplay(&chunkConn{}) | |||
| 47 | + | p := &BaseProxy{id: d.AllocateID(), context: d.context} | |||
| 48 | + | cycle := func() { | |||
| 49 | + | d.context.Register(p) | |||
| 50 | + | d.context.Unregister(p) | |||
| 51 | + | } | |||
| 52 | + | cycle() | |||
| 53 | + | if n := testing.AllocsPerRun(100, cycle); n != 0 { | |||
| 54 | + | t.Fatalf("register/unregister allocated %v times per cycle, want 0", n) | |||
| 55 | + | } | |||
| 56 | + | }) | |||
| 42 | } | 57 | } | ||
M client.go +2 -2
| @@ -46,7 +46,7 @@ type Object interface { | |||
| 46 | 46 | type Display struct { | |
| 47 | 47 | conn net.Conn | |
| 48 | 48 | unix *net.UnixConn // set when conn is a Unix socket, nil otherwise | |
| 49 | - | objects sync.Map // map[uint32]Object | |
| 49 | + | objects objectTable[Object] | |
| 50 | 50 | nextID uint32 | |
| 51 | 51 | idMu sync.Mutex | |
| 52 | 52 | freeIDs []uint32 | |
| @@ -608,7 +608,7 @@ func (d *Display) prepareFrame(f receivedFrame) (Object, *Event, error) { | |||
| 608 | 608 | } | |
| 609 | 609 | // For a zombie the descriptors were still consumed above so the stream | |
| 610 | 610 | // stays aligned; dispatchFrame closes them without running any handler. | |
| 611 | - | return obj.(Object), ev, nil | |
| 611 | + | return obj, ev, nil | |
| 612 | 612 | } | |
| 613 | 613 | ||
| 614 | 614 | type signatureKey struct { | |
| @@ -46,7 +46,7 @@ type Object interface { | |||||
| 46 | type Display struct { | 46 | type Display struct { | ||
| 47 | conn net.Conn | 47 | conn net.Conn | ||
| 48 | unix *net.UnixConn // set when conn is a Unix socket, nil otherwise | 48 | unix *net.UnixConn // set when conn is a Unix socket, nil otherwise | ||
| 49 | - | objects sync.Map // map[uint32]Object | 49 | + | objects objectTable[Object] |
| 50 | nextID uint32 | 50 | nextID uint32 | ||
| 51 | idMu sync.Mutex | 51 | idMu sync.Mutex | ||
| 52 | freeIDs []uint32 | 52 | freeIDs []uint32 | ||
| @@ -608,7 +608,7 @@ func (d *Display) prepareFrame(f receivedFrame) (Object, *Event, error) { | |||||
| 608 | } | 608 | } | ||
| 609 | // For a zombie the descriptors were still consumed above so the stream | 609 | // For a zombie the descriptors were still consumed above so the stream | ||
| 610 | // stays aligned; dispatchFrame closes them without running any handler. | 610 | // stays aligned; dispatchFrame closes them without running any handler. | ||
| 611 | - | return obj.(Object), ev, nil | 611 | + | return obj, ev, nil |
| 612 | } | 612 | } | ||
| 613 | 613 | ||||
| 614 | type signatureKey struct { | 614 | type signatureKey struct { | ||
M core.go +1 -1
| @@ -13,7 +13,7 @@ import ( | |||
| 13 | 13 | // Context provides a compatibility layer for wl.Context | |
| 14 | 14 | type Context struct { | |
| 15 | 15 | display *Display | |
| 16 | - | proxies sync.Map // map[uint32]Proxy | |
| 16 | + | proxies objectTable[Proxy] | |
| 17 | 17 | closed atomic.Bool | |
| 18 | 18 | } | |
| 19 | 19 | ||
| @@ -13,7 +13,7 @@ import ( | |||||
| 13 | // Context provides a compatibility layer for wl.Context | 13 | // Context provides a compatibility layer for wl.Context | ||
| 14 | type Context struct { | 14 | type Context struct { | ||
| 15 | display *Display | 15 | display *Display | ||
| 16 | - | proxies sync.Map // map[uint32]Proxy | 16 | + | proxies objectTable[Proxy] |
| 17 | closed atomic.Bool | 17 | closed atomic.Bool | ||
| 18 | } | 18 | } | ||
| 19 | 19 | ||||
A objects.go +56 -0
| @@ -0,0 +1,56 @@ | |||
| 1 | + | package wlturbo | |
| 2 | + | ||
| 3 | + | import "sync" | |
| 4 | + | ||
| 5 | + | // objectTable maps object IDs to objects. Unlike sync.Map, storing an ID that | |
| 6 | + | // was deleted earlier reuses the map's storage, so the steady-state cycle of | |
| 7 | + | // server-created objects (wl_callback per frame, recycled IDs) does not | |
| 8 | + | // allocate. | |
| 9 | + | type objectTable[V comparable] struct { | |
| 10 | + | mu sync.RWMutex | |
| 11 | + | m map[uint32]V | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func (t *objectTable[V]) Load(id uint32) (V, bool) { | |
| 15 | + | t.mu.RLock() | |
| 16 | + | v, ok := t.m[id] | |
| 17 | + | t.mu.RUnlock() | |
| 18 | + | return v, ok | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (t *objectTable[V]) Store(id uint32, v V) { | |
| 22 | + | t.mu.Lock() | |
| 23 | + | if t.m == nil { | |
| 24 | + | t.m = make(map[uint32]V) | |
| 25 | + | } | |
| 26 | + | t.m[id] = v | |
| 27 | + | t.mu.Unlock() | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (t *objectTable[V]) Delete(id uint32) { | |
| 31 | + | t.mu.Lock() | |
| 32 | + | delete(t.m, id) | |
| 33 | + | t.mu.Unlock() | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // CompareAndDelete deletes id only while it maps to old. | |
| 37 | + | func (t *objectTable[V]) CompareAndDelete(id uint32, old V) bool { | |
| 38 | + | t.mu.Lock() | |
| 39 | + | defer t.mu.Unlock() | |
| 40 | + | if v, ok := t.m[id]; !ok || v != old { | |
| 41 | + | return false | |
| 42 | + | } | |
| 43 | + | delete(t.m, id) | |
| 44 | + | return true | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // CompareAndSwap replaces id's value only while it maps to old. | |
| 48 | + | func (t *objectTable[V]) CompareAndSwap(id uint32, old, new V) bool { | |
| 49 | + | t.mu.Lock() | |
| 50 | + | defer t.mu.Unlock() | |
| 51 | + | if v, ok := t.m[id]; !ok || v != old { | |
| 52 | + | return false | |
| 53 | + | } | |
| 54 | + | t.m[id] = new | |
| 55 | + | return true | |
| 56 | + | } | |
| @@ -0,0 +1,56 @@ | |||||
| 1 | + | package wlturbo | |||
| 2 | + | ||||
| 3 | + | import "sync" | |||
| 4 | + | ||||
| 5 | + | // objectTable maps object IDs to objects. Unlike sync.Map, storing an ID that | |||
| 6 | + | // was deleted earlier reuses the map's storage, so the steady-state cycle of | |||
| 7 | + | // server-created objects (wl_callback per frame, recycled IDs) does not | |||
| 8 | + | // allocate. | |||
| 9 | + | type objectTable[V comparable] struct { | |||
| 10 | + | mu sync.RWMutex | |||
| 11 | + | m map[uint32]V | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func (t *objectTable[V]) Load(id uint32) (V, bool) { | |||
| 15 | + | t.mu.RLock() | |||
| 16 | + | v, ok := t.m[id] | |||
| 17 | + | t.mu.RUnlock() | |||
| 18 | + | return v, ok | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (t *objectTable[V]) Store(id uint32, v V) { | |||
| 22 | + | t.mu.Lock() | |||
| 23 | + | if t.m == nil { | |||
| 24 | + | t.m = make(map[uint32]V) | |||
| 25 | + | } | |||
| 26 | + | t.m[id] = v | |||
| 27 | + | t.mu.Unlock() | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (t *objectTable[V]) Delete(id uint32) { | |||
| 31 | + | t.mu.Lock() | |||
| 32 | + | delete(t.m, id) | |||
| 33 | + | t.mu.Unlock() | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // CompareAndDelete deletes id only while it maps to old. | |||
| 37 | + | func (t *objectTable[V]) CompareAndDelete(id uint32, old V) bool { | |||
| 38 | + | t.mu.Lock() | |||
| 39 | + | defer t.mu.Unlock() | |||
| 40 | + | if v, ok := t.m[id]; !ok || v != old { | |||
| 41 | + | return false | |||
| 42 | + | } | |||
| 43 | + | delete(t.m, id) | |||
| 44 | + | return true | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // CompareAndSwap replaces id's value only while it maps to old. | |||
| 48 | + | func (t *objectTable[V]) CompareAndSwap(id uint32, old, new V) bool { | |||
| 49 | + | t.mu.Lock() | |||
| 50 | + | defer t.mu.Unlock() | |||
| 51 | + | if v, ok := t.m[id]; !ok || v != old { | |||
| 52 | + | return false | |||
| 53 | + | } | |||
| 54 | + | t.m[id] = new | |||
| 55 | + | return true | |||
| 56 | + | } | |||