detached 560aaa9

perf: remove FFI reflection and per-frame allocations from the hot path (#31)

Bump purego to v0.13.0-bnema.1, purego-vulkan to v0.5.0 and
purego-libwayland to v0.5.0: libwayland and Vulkan are now called through
fixed-arity zero-allocation syscalls and C calls back into Go without
reflection. Vulkan command availability is checked with Has<Command>().

Remove the remaining per-frame allocators: DRM presentation reports and
flip Shows reuse immutable snapshots; the frame pacer reuses its maps and
slices; the Vulkan renderer reuses draw, cover, damage, fence and
semaphore scratch; surface updates are recycled once no queued update
references them through deps or prev; input-region traversal no longer
allocates a closure.

Keep the runtime default GOGC: with per-frame garbage gone, GOGC=50 ran
13 collections (91 ms) on the tiled playback test where 100 runs 2 (3 ms).

Add --pprof to serve Go runtime profiles, extend perf-check with
allocation guards for every path above, and document how to profile and
the reference numbers in docs/performance.md.

During 4K HDR tiled video playback CPU drops from 20-30% to 13-14% and
the GC share of the profile from 20% to 8.7%; reflection frames are gone.

Closes #19
commit
560aaa9992f4cb60ef9282ab4905e5d88efd6348
author
bnema · · 2026-09-27 18:52 UTC
committer
GitHub · · 2026-09-27 18:52 UTC
parent
541e2c7
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
23 files · +546 -81
M Makefile +1 -1
@@ -50,7 +50,7 @@ arch:
5050 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
5151 # Fast, mandatory guards for tiled content publications and SHM copies.
5252 perf-check:
53- CGO_ENABLED=0 go test ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
53+ CGO_ENABLED=0 go test ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestReportSeenAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
5454 check: vet test arch fakes-check perf-check
5555
5656 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules
@@ -50,7 +50,7 @@ arch:
50 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...50 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
51 # Fast, mandatory guards for tiled content publications and SHM copies.51 # Fast, mandatory guards for tiled content publications and SHM copies.
52 perf-check:52 perf-check:
53- CGO_ENABLED=0 go test ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=153+ CGO_ENABLED=0 go test ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestReportSeenAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
54 check: vet test arch fakes-check perf-check54 check: vet test arch fakes-check perf-check
55 55
56 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules56 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules
M README.md +1 -0
@@ -98,6 +98,7 @@ The file has one `key = value` per line, and `#` starts a comment. A missing fil
9898 - [Configuration](docs/config.md): every key, action and default bind, and HDR.
9999 - [Desktop integration](docs/desktop.md): running commands, clipboard, X11 apps, idle and screen off, bars, and the state file for scripts.
100100 - [Headless mode](docs/headless.md): run without a screen, take screenshots and play input scripts.
101+- [Performance](docs/performance.md): how to profile, the allocation guards, and reference numbers.
101102
102103 ## Why not Rust?
103104
@@ -98,6 +98,7 @@ The file has one `key = value` per line, and `#` starts a comment. A missing fil
98 - [Configuration](docs/config.md): every key, action and default bind, and HDR.98 - [Configuration](docs/config.md): every key, action and default bind, and HDR.
99 - [Desktop integration](docs/desktop.md): running commands, clipboard, X11 apps, idle and screen off, bars, and the state file for scripts.99 - [Desktop integration](docs/desktop.md): running commands, clipboard, X11 apps, idle and screen off, bars, and the state file for scripts.
100 - [Headless mode](docs/headless.md): run without a screen, take screenshots and play input scripts.100 - [Headless mode](docs/headless.md): run without a screen, take screenshots and play input scripts.
101+- [Performance](docs/performance.md): how to profile, the allocation guards, and reference numbers.
101 102
102 ## Why not Rust?103 ## Why not Rust?
103 104
M cmd/neferwl/main.go +40 -8
@@ -6,6 +6,9 @@ import (
66 "errors"
77 "flag"
88 "fmt"
9+ "net"
10+ "net/http"
11+ "net/http/pprof"
912 "os"
1013 "os/signal"
1114 "runtime/debug"
@@ -31,19 +34,40 @@ func main() {
3134 os.Exit(runCode())
3235 }
3336
34-// tuneGC keeps the heap close to what neferwl uses: a compositor runs for
35-// days and its per-frame garbage is small, so the default GOGC=100 (heap
36-// grows to twice the live data) mostly holds freed memory. A soft limit
37-// makes the collector work harder before the process gets large. GOGC and
38-// GOMEMLIMIT set in the environment win.
37+// tuneGC bounds the heap of a process that runs for days. The Go live heap
38+// is small (tens of MB; buffers live in GPU and shared memory), so the
39+// default GOGC=100 is kept: on the tiled playback test, GOGC=50 ran 13
40+// collections for 91 ms of GC CPU where GOGC=100 ran 2 for 3 ms, for 2 MB
41+// more heap. A soft limit makes the collector work harder before the
42+// process gets large. GOGC and GOMEMLIMIT set in the environment win.
3943 func tuneGC() {
40- if os.Getenv("GOGC") == "" {
41- debug.SetGCPercent(50)
42- }
4344 if os.Getenv("GOMEMLIMIT") == "" {
4445 debug.SetMemoryLimit(256 << 20)
4546 }
4647 }
48+
49+// servePprof exposes the runtime profiles under /debug/pprof/ until ctx is
50+// done. It binds before returning so a bad address fails at startup.
51+func servePprof(ctx context.Context, addr string) error {
52+ ln, err := net.Listen("tcp", addr)
53+ if err != nil {
54+ return fmt.Errorf("--pprof: %w", err)
55+ }
56+ mux := http.NewServeMux()
57+ mux.HandleFunc("/debug/pprof/", pprof.Index)
58+ mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
59+ mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
60+ mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
61+ mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
62+ srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
63+ go func() { _ = srv.Serve(ln) }()
64+ go func() {
65+ <-ctx.Done()
66+ _ = srv.Close()
67+ }()
68+ return nil
69+}
70+
4771 func runCode() int {
4872 err := run()
4973 if err == nil || errors.Is(err, flag.ErrHelp) || errors.Is(err, context.Canceled) {
@@ -123,6 +147,7 @@ func run() error {
123147 debugFlag := flags.String("debug", "", "debug components (comma-separated or all; input-motion logs every pointer motion)")
124148 configFlag := flags.String("config", "", "config path (empty uses XDG default)")
125149 session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")
150+ pprofAddr := flags.String("pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")
126151 if err := flags.Parse(os.Args[1:]); err != nil {
127152 if errors.Is(err, flag.ErrHelp) {
128153 return err
@@ -224,6 +249,13 @@ func run() error {
224249 panic(p)
225250 }
226251 }()
252+ if *pprofAddr != "" {
253+ if err := servePprof(ctx, *pprofAddr); err != nil {
254+ fmt.Fprintln(os.Stderr, err)
255+ return err
256+ }
257+ log.Info().Str("addr", *pprofAddr).Msg("pprof")
258+ }
227259 start := time.Now()
228260 log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run")
229261 configLog := logging.For(ctx, "config")
@@ -6,6 +6,9 @@ import (
6 "errors"6 "errors"
7 "flag"7 "flag"
8 "fmt"8 "fmt"
9+ "net"
10+ "net/http"
11+ "net/http/pprof"
9 "os"12 "os"
10 "os/signal"13 "os/signal"
11 "runtime/debug"14 "runtime/debug"
@@ -31,19 +34,40 @@ func main() {
31 os.Exit(runCode())34 os.Exit(runCode())
32 }35 }
33 36
34-// tuneGC keeps the heap close to what neferwl uses: a compositor runs for37+// tuneGC bounds the heap of a process that runs for days. The Go live heap
35-// days and its per-frame garbage is small, so the default GOGC=100 (heap38+// is small (tens of MB; buffers live in GPU and shared memory), so the
36-// grows to twice the live data) mostly holds freed memory. A soft limit39+// default GOGC=100 is kept: on the tiled playback test, GOGC=50 ran 13
37-// makes the collector work harder before the process gets large. GOGC and40+// collections for 91 ms of GC CPU where GOGC=100 ran 2 for 3 ms, for 2 MB
38-// GOMEMLIMIT set in the environment win.41+// more heap. A soft limit makes the collector work harder before the
42+// process gets large. GOGC and GOMEMLIMIT set in the environment win.
39 func tuneGC() {43 func tuneGC() {
40- if os.Getenv("GOGC") == "" {
41- debug.SetGCPercent(50)
42- }
43 if os.Getenv("GOMEMLIMIT") == "" {44 if os.Getenv("GOMEMLIMIT") == "" {
44 debug.SetMemoryLimit(256 << 20)45 debug.SetMemoryLimit(256 << 20)
45 }46 }
46 }47 }
48+
49+// servePprof exposes the runtime profiles under /debug/pprof/ until ctx is
50+// done. It binds before returning so a bad address fails at startup.
51+func servePprof(ctx context.Context, addr string) error {
52+ ln, err := net.Listen("tcp", addr)
53+ if err != nil {
54+ return fmt.Errorf("--pprof: %w", err)
55+ }
56+ mux := http.NewServeMux()
57+ mux.HandleFunc("/debug/pprof/", pprof.Index)
58+ mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
59+ mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
60+ mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
61+ mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
62+ srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
63+ go func() { _ = srv.Serve(ln) }()
64+ go func() {
65+ <-ctx.Done()
66+ _ = srv.Close()
67+ }()
68+ return nil
69+}
70+
47 func runCode() int {71 func runCode() int {
48 err := run()72 err := run()
49 if err == nil || errors.Is(err, flag.ErrHelp) || errors.Is(err, context.Canceled) {73 if err == nil || errors.Is(err, flag.ErrHelp) || errors.Is(err, context.Canceled) {
@@ -123,6 +147,7 @@ func run() error {
123 debugFlag := flags.String("debug", "", "debug components (comma-separated or all; input-motion logs every pointer motion)")147 debugFlag := flags.String("debug", "", "debug components (comma-separated or all; input-motion logs every pointer motion)")
124 configFlag := flags.String("config", "", "config path (empty uses XDG default)")148 configFlag := flags.String("config", "", "config path (empty uses XDG default)")
125 session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")149 session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")
150+ pprofAddr := flags.String("pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")
126 if err := flags.Parse(os.Args[1:]); err != nil {151 if err := flags.Parse(os.Args[1:]); err != nil {
127 if errors.Is(err, flag.ErrHelp) {152 if errors.Is(err, flag.ErrHelp) {
128 return err153 return err
@@ -224,6 +249,13 @@ func run() error {
224 panic(p)249 panic(p)
225 }250 }
226 }()251 }()
252+ if *pprofAddr != "" {
253+ if err := servePprof(ctx, *pprofAddr); err != nil {
254+ fmt.Fprintln(os.Stderr, err)
255+ return err
256+ }
257+ log.Info().Str("addr", *pprofAddr).Msg("pprof")
258+ }
227 start := time.Now()259 start := time.Now()
228 log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run")260 log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run")
229 configLog := logging.For(ctx, "config")261 configLog := logging.For(ctx, "config")
A docs/performance.md +62 -0
@@ -0,0 +1,62 @@
1+# Performance
2+
3+How NeferWL keeps its CPU and memory use low, how to measure it, and the reference numbers to compare against.
4+
5+## Design
6+
7+- **No reflection on the FFI boundary.** libwayland and Vulkan are called through `purego.Syscall6/Syscall15` (fixed arity, zero allocation) and C calls back into Go through `purego.NewCallbackInts`. The Vulkan dispatch tables are generated methods; the libwayland wrapper caches resource id, version and client, and precomputes request argument counts.
8+- **No allocation per frame or per request on the steady-state path.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.
9+- **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.
10+
11+## Guards
12+
13+`make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, pacer bookkeeping, surface update recycling and input-region traversal. Run it with `make check`.
14+
15+## Measuring
16+
17+Profile a running compositor on another TTY or over SSH.
18+
19+```sh
20+# CPU: where the cycles go (Go and C frames).
21+perf record -g -p (pgrep -f bin/neferwl) -- sleep 8
22+perf report --children --percent-limit 1
23+
24+# Allocations: which Go line allocates how many objects.
25+neferwl --backend=drm --pprof=localhost:6060
26+go tool pprof -sample_index=alloc_objects -top http://localhost:6060/debug/pprof/heap
27+
28+# GC: one line per collection (count, heap before/after, CPU).
29+GODEBUG=gctrace=1 neferwl --backend=drm
30+```
31+
32+`--pprof` also serves `/debug/pprof/profile` (CPU) and `/debug/pprof/trace` (scheduler and GC timeline for `go tool trace`). Escape analysis (`go build -gcflags=-m ./internal/adapters/vulkan`) shows why a value reaches the heap.
33+
34+## Reference: 4K HDR tiled video playback
35+
36+Workload: a browser playing 4K HDR video with 65 tiled subsurfaces committing every frame, DRM backend, `perf record -g` for 8 s. Percentages are the share of the process's own samples (inclusive).
37+
38+| Metric | Before (issue #19) | 2026-09-27 |
39+|---|---|---|
40+| CPU (top) | 20–30 % | 13–14 % |
41+| GC (`gcBgMarkWorker`) | 20 % | 8.7 % |
42+| `runtime.mallocgc` | — | 3.2 % |
43+| Go → C reflection (`purego.RegisterFunc`) | 46.7 % | 0 |
44+| C → Go reflection (`reflect.Value.Call`) | ~30 % | 0 |
45+| Request handlers (`server.dispatch`) | 25.9 % | 27.6 % (real work) |
46+| Vulkan `Render` | — | 15.3 % |
47+
48+Component numbers (linux/amd64, Go 1.27, `CGO_ENABLED=0`):
49+
50+| Path | Before | Now |
51+|---|---|---|
52+| C → Go callback, 5 args (purego) | 428 ns, 3 allocs | 101 ns, 0 |
53+| Go → C, 6 args (purego) | 71 ns, 1 alloc | 34 ns, 0 |
54+| Wayland request dispatch (purego-libwayland) | 27 ns, 1 alloc | 5 ns, 0 |
55+| Wayland `PostEvent`, 3 args | 344 ns, 5 allocs | 41 ns, 0 |
56+| Vulkan Reset+Begin+Barrier+End (purego-vulkan) | 6.4 µs, 22 allocs | 1.2 µs, 0 |
57+| `Render`, tiled scene, per frame | 1321 allocs | 10 allocs |
58+| Surface update per commit | 2 allocs | 0 (recycled) |
59+| DRM report / flip `Shows` per flip | 2 maps | 0 (immutable snapshots) |
60+| GC on tiled playback test | 13 cycles, 91 ms | 2 cycles, 3 ms |
61+
62+What remains is compositor work: `surface.Commit` and the fifo logic (~23 %), real C calls (~5 %), the Go scheduler (~8 %).
@@ -0,0 +1,62 @@
1+# Performance
2+
3+How NeferWL keeps its CPU and memory use low, how to measure it, and the reference numbers to compare against.
4+
5+## Design
6+
7+- **No reflection on the FFI boundary.** libwayland and Vulkan are called through `purego.Syscall6/Syscall15` (fixed arity, zero allocation) and C calls back into Go through `purego.NewCallbackInts`. The Vulkan dispatch tables are generated methods; the libwayland wrapper caches resource id, version and client, and precomputes request argument counts.
8+- **No allocation per frame or per request on the steady-state path.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them.
9+- **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win.
10+
11+## Guards
12+
13+`make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, pacer bookkeeping, surface update recycling and input-region traversal. Run it with `make check`.
14+
15+## Measuring
16+
17+Profile a running compositor on another TTY or over SSH.
18+
19+```sh
20+# CPU: where the cycles go (Go and C frames).
21+perf record -g -p (pgrep -f bin/neferwl) -- sleep 8
22+perf report --children --percent-limit 1
23+
24+# Allocations: which Go line allocates how many objects.
25+neferwl --backend=drm --pprof=localhost:6060
26+go tool pprof -sample_index=alloc_objects -top http://localhost:6060/debug/pprof/heap
27+
28+# GC: one line per collection (count, heap before/after, CPU).
29+GODEBUG=gctrace=1 neferwl --backend=drm
30+```
31+
32+`--pprof` also serves `/debug/pprof/profile` (CPU) and `/debug/pprof/trace` (scheduler and GC timeline for `go tool trace`). Escape analysis (`go build -gcflags=-m ./internal/adapters/vulkan`) shows why a value reaches the heap.
33+
34+## Reference: 4K HDR tiled video playback
35+
36+Workload: a browser playing 4K HDR video with 65 tiled subsurfaces committing every frame, DRM backend, `perf record -g` for 8 s. Percentages are the share of the process's own samples (inclusive).
37+
38+| Metric | Before (issue #19) | 2026-09-27 |
39+|---|---|---|
40+| CPU (top) | 20–30 % | 13–14 % |
41+| GC (`gcBgMarkWorker`) | 20 % | 8.7 % |
42+| `runtime.mallocgc` | — | 3.2 % |
43+| Go → C reflection (`purego.RegisterFunc`) | 46.7 % | 0 |
44+| C → Go reflection (`reflect.Value.Call`) | ~30 % | 0 |
45+| Request handlers (`server.dispatch`) | 25.9 % | 27.6 % (real work) |
46+| Vulkan `Render` | — | 15.3 % |
47+
48+Component numbers (linux/amd64, Go 1.27, `CGO_ENABLED=0`):
49+
50+| Path | Before | Now |
51+|---|---|---|
52+| C → Go callback, 5 args (purego) | 428 ns, 3 allocs | 101 ns, 0 |
53+| Go → C, 6 args (purego) | 71 ns, 1 alloc | 34 ns, 0 |
54+| Wayland request dispatch (purego-libwayland) | 27 ns, 1 alloc | 5 ns, 0 |
55+| Wayland `PostEvent`, 3 args | 344 ns, 5 allocs | 41 ns, 0 |
56+| Vulkan Reset+Begin+Barrier+End (purego-vulkan) | 6.4 µs, 22 allocs | 1.2 µs, 0 |
57+| `Render`, tiled scene, per frame | 1321 allocs | 10 allocs |
58+| Surface update per commit | 2 allocs | 0 (recycled) |
59+| DRM report / flip `Shows` per flip | 2 maps | 0 (immutable snapshots) |
60+| GC on tiled playback test | 13 cycles, 91 ms | 2 cycles, 3 ms |
61+
62+What remains is compositor work: `surface.Commit` and the fifo logic (~23 %), real C calls (~5 %), the Go scheduler (~8 %).
M go.mod +3 -3
@@ -3,9 +3,9 @@ module github.com/bnema/neferwl
33 go 1.27
44
55 require (
6- github.com/bnema/purego v0.12.0-bnema.1
7- github.com/bnema/purego-libwayland v0.4.1
8- github.com/bnema/purego-vulkan v0.4.2
6+ github.com/bnema/purego v0.13.0-bnema.1
7+ github.com/bnema/purego-libwayland v0.5.0
8+ github.com/bnema/purego-vulkan v0.5.0
99 github.com/bnema/wlturbo v0.2.0
1010 github.com/bnema/zerowrap v1.4.1
1111 github.com/rs/zerolog v1.35.1
@@ -3,9 +3,9 @@ module github.com/bnema/neferwl
3 go 1.273 go 1.27
4 4
5 require (5 require (
6- github.com/bnema/purego v0.12.0-bnema.16+ github.com/bnema/purego v0.13.0-bnema.1
7- github.com/bnema/purego-libwayland v0.4.17+ github.com/bnema/purego-libwayland v0.5.0
8- github.com/bnema/purego-vulkan v0.4.28+ github.com/bnema/purego-vulkan v0.5.0
9 github.com/bnema/wlturbo v0.2.09 github.com/bnema/wlturbo v0.2.0
10 github.com/bnema/zerowrap v1.4.110 github.com/bnema/zerowrap v1.4.1
11 github.com/rs/zerolog v1.35.111 github.com/rs/zerolog v1.35.1
M go.sum +6 -6 generated file: collapsed
M internal/adapters/drm/output.go +65 -11
@@ -93,6 +93,12 @@ type Output struct {
9393 shown, queued uint64
9494 reason string // why the last frame was composed ("" = scanout)
9595 unsent []ports.OutputPresented
96+ // Last immutable snapshot sent in a report. Never mutate it: wayland may
97+ // still be reading a prior report on another goroutine.
98+ seenSnapshot map[ports.WindowID]uint64
99+ showsSnapshot map[ports.WindowID]uint64
100+ directShowsSnapshot map[ports.WindowID]uint64
101+ showsScratch map[ports.WindowID]uint64
96102 // Tearing (ADR 006): async commits of scanned-out buffers that ask for
97103 // them, when the card has DRM_CAP_ATOMIC_ASYNC_PAGE_FLIP. asyncFence
98104 // is set when an async commit may carry IN_FENCE_FD (probed once).
@@ -967,6 +973,17 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
967973 }()
968974 stats := time.NewTicker(10 * time.Second)
969975 defer stats.Stop()
976+ // Timer channels are enabled only when their condition holds. Go 1.27
977+ // guarantees a stopped/reset timer cannot deliver its old value.
978+ retryTimer := time.NewTimer(time.Hour)
979+ vrrTimer := time.NewTimer(time.Hour)
980+ stuckTimer := time.NewTimer(time.Hour)
981+ retryTimer.Stop()
982+ vrrTimer.Stop()
983+ stuckTimer.Stop()
984+ defer retryTimer.Stop()
985+ defer vrrTimer.Stop()
986+ defer stuckTimer.Stop()
970987 // seen is the latest content Seq per window. It is reported only while
971988 // no frame is in flight: then the GPU has finished every frame that
972989 // read older buffers (the kernel waited for their fence to flip).
@@ -989,17 +1006,26 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
9891006 // so is one that waits for an uncommitted frame's fence.
9901007 var retry <-chan time.Time
9911008 if len(o.unsent) > 0 || readWait {
992- retry = time.After(time.Millisecond)
1009+ retryTimer.Reset(time.Millisecond)
1010+ retry = retryTimer.C
1011+ } else {
1012+ retryTimer.Stop()
9931013 }
9941014 // A composed screen that stopped changing still drops VRR on time.
9951015 var vrrOff <-chan time.Time
9961016 if enabled && o.vrrOn && !o.pending && !o.composedSince.IsZero() {
997- vrrOff = time.After(max(0, vrrHold-time.Since(o.composedSince)))
1017+ vrrTimer.Reset(max(0, vrrHold-time.Since(o.composedSince)))
1018+ vrrOff = vrrTimer.C
1019+ } else {
1020+ vrrTimer.Stop()
9981021 }
9991022 // A commit whose event never comes must not stop the output.
10001023 var stuck <-chan time.Time
10011024 if enabled && o.pending {
1002- stuck = time.After(max(0, time.Until(o.stuckAt)))
1025+ stuckTimer.Reset(max(0, time.Until(o.stuckAt)))
1026+ stuck = stuckTimer.C
1027+ } else {
1028+ stuckTimer.Stop()
10031029 }
10041030 stateDirty := false
10051031 select {
@@ -1144,7 +1170,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11441170 start := time.Now()
11451171 // shows is what the frame puts on screen: presentation feedback
11461172 // of windows it does not draw is discarded, not presented.
1147- f := pendingFrame{shows: shownBy(scene, seen)}
1173+ f := pendingFrame{shows: o.shownBy(scene, seen)}
11481174 var fb uint32
11491175 var c ports.SurfaceContent
11501176 forceCompose := len(requests) > 0
@@ -1153,7 +1179,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11531179 }
11541180 direct := false
11551181 if fb != 0 {
1156- direct, err = o.commitScanout(fb, c, pendingFrame{shows: map[ports.WindowID]uint64{c.ID: seen[c.ID]}})
1182+ direct, err = o.commitScanout(fb, c, pendingFrame{shows: o.directShownBy(c.ID, seen[c.ID])})
11571183 }
11581184 if !direct {
11591185 var ov overlayWin
@@ -1312,7 +1338,10 @@ const maxUnsent = 16
13121338 // buffers can be released. A flip-less report merges into the newest
13131339 // queued one; flips are never merged unless maxUnsent is reached.
13141340 func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
1315- r := ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: maps.Clone(seen)}
1341+ if !maps.Equal(o.seenSnapshot, seen) {
1342+ o.seenSnapshot = maps.Clone(seen)
1343+ }
1344+ r := ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: o.seenSnapshot}
13161345 if n := len(o.unsent); n > 0 && (flip == nil || o.unsent[n-1].Flip == nil) {
13171346 if flip == nil {
13181347 r.Flip = o.unsent[n-1].Flip
@@ -1325,8 +1354,18 @@ func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
13251354 old, next := o.unsent[0].Flip, o.unsent[1].Flip
13261355 if next != nil && old != nil {
13271356 next.Merged += 1 + old.Merged
1357+ // A snapshot can be shared with previously sent flips. Clone only
1358+ // when a merge actually needs to add an absent window.
1359+ cloned := false
13281360 for id, seq := range old.Shows {
13291361 if _, ok := next.Shows[id]; !ok {
1362+ if !cloned {
1363+ next.Shows = maps.Clone(next.Shows)
1364+ cloned = true
1365+ }
1366+ if next.Shows == nil {
1367+ next.Shows = make(map[ports.WindowID]uint64)
1368+ }
13301369 next.Shows[id] = seq
13311370 }
13321371 }
@@ -1529,13 +1568,28 @@ func (o *Output) freeImages() {
15291568 }
15301569 }
15311570
1532-// shownBy is the content Seq of each window the scene draws.
1533-func shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[ports.WindowID]uint64 {
1534- out := map[ports.WindowID]uint64{}
1571+// shownBy returns an immutable snapshot of the content Seq of drawn windows.
1572+// The scratch map is private to the output; a snapshot may still be read by
1573+// wayland after a subsequent flip, so never refill a previously sent map.
1574+func (o *Output) shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[ports.WindowID]uint64 {
1575+ if o.showsScratch == nil {
1576+ o.showsScratch = make(map[ports.WindowID]uint64)
1577+ }
1578+ clear(o.showsScratch)
15351579 for id, seq := range seen {
15361580 if s.Shows(id) {
1537- out[id] = seq
1581+ o.showsScratch[id] = seq
15381582 }
15391583 }
1540- return out
1584+ if !maps.Equal(o.showsSnapshot, o.showsScratch) {
1585+ o.showsSnapshot = maps.Clone(o.showsScratch)
1586+ }
1587+ return o.showsSnapshot
1588+}
1589+
1590+func (o *Output) directShownBy(id ports.WindowID, seq uint64) map[ports.WindowID]uint64 {
1591+ if len(o.directShowsSnapshot) != 1 || o.directShowsSnapshot[id] != seq {
1592+ o.directShowsSnapshot = map[ports.WindowID]uint64{id: seq}
1593+ }
1594+ return o.directShowsSnapshot
15411595 }
@@ -93,6 +93,12 @@ type Output struct {
93 shown, queued uint6493 shown, queued uint64
94 reason string // why the last frame was composed ("" = scanout)94 reason string // why the last frame was composed ("" = scanout)
95 unsent []ports.OutputPresented95 unsent []ports.OutputPresented
96+ // Last immutable snapshot sent in a report. Never mutate it: wayland may
97+ // still be reading a prior report on another goroutine.
98+ seenSnapshot map[ports.WindowID]uint64
99+ showsSnapshot map[ports.WindowID]uint64
100+ directShowsSnapshot map[ports.WindowID]uint64
101+ showsScratch map[ports.WindowID]uint64
96 // Tearing (ADR 006): async commits of scanned-out buffers that ask for102 // Tearing (ADR 006): async commits of scanned-out buffers that ask for
97 // them, when the card has DRM_CAP_ATOMIC_ASYNC_PAGE_FLIP. asyncFence103 // them, when the card has DRM_CAP_ATOMIC_ASYNC_PAGE_FLIP. asyncFence
98 // is set when an async commit may carry IN_FENCE_FD (probed once).104 // is set when an async commit may carry IN_FENCE_FD (probed once).
@@ -967,6 +973,17 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
967 }()973 }()
968 stats := time.NewTicker(10 * time.Second)974 stats := time.NewTicker(10 * time.Second)
969 defer stats.Stop()975 defer stats.Stop()
976+ // Timer channels are enabled only when their condition holds. Go 1.27
977+ // guarantees a stopped/reset timer cannot deliver its old value.
978+ retryTimer := time.NewTimer(time.Hour)
979+ vrrTimer := time.NewTimer(time.Hour)
980+ stuckTimer := time.NewTimer(time.Hour)
981+ retryTimer.Stop()
982+ vrrTimer.Stop()
983+ stuckTimer.Stop()
984+ defer retryTimer.Stop()
985+ defer vrrTimer.Stop()
986+ defer stuckTimer.Stop()
970 // seen is the latest content Seq per window. It is reported only while987 // seen is the latest content Seq per window. It is reported only while
971 // no frame is in flight: then the GPU has finished every frame that988 // no frame is in flight: then the GPU has finished every frame that
972 // read older buffers (the kernel waited for their fence to flip).989 // read older buffers (the kernel waited for their fence to flip).
@@ -989,17 +1006,26 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
989 // so is one that waits for an uncommitted frame's fence.1006 // so is one that waits for an uncommitted frame's fence.
990 var retry <-chan time.Time1007 var retry <-chan time.Time
991 if len(o.unsent) > 0 || readWait {1008 if len(o.unsent) > 0 || readWait {
992- retry = time.After(time.Millisecond)1009+ retryTimer.Reset(time.Millisecond)
1010+ retry = retryTimer.C
1011+ } else {
1012+ retryTimer.Stop()
993 }1013 }
994 // A composed screen that stopped changing still drops VRR on time.1014 // A composed screen that stopped changing still drops VRR on time.
995 var vrrOff <-chan time.Time1015 var vrrOff <-chan time.Time
996 if enabled && o.vrrOn && !o.pending && !o.composedSince.IsZero() {1016 if enabled && o.vrrOn && !o.pending && !o.composedSince.IsZero() {
997- vrrOff = time.After(max(0, vrrHold-time.Since(o.composedSince)))1017+ vrrTimer.Reset(max(0, vrrHold-time.Since(o.composedSince)))
1018+ vrrOff = vrrTimer.C
1019+ } else {
1020+ vrrTimer.Stop()
998 }1021 }
999 // A commit whose event never comes must not stop the output.1022 // A commit whose event never comes must not stop the output.
1000 var stuck <-chan time.Time1023 var stuck <-chan time.Time
1001 if enabled && o.pending {1024 if enabled && o.pending {
1002- stuck = time.After(max(0, time.Until(o.stuckAt)))1025+ stuckTimer.Reset(max(0, time.Until(o.stuckAt)))
1026+ stuck = stuckTimer.C
1027+ } else {
1028+ stuckTimer.Stop()
1003 }1029 }
1004 stateDirty := false1030 stateDirty := false
1005 select {1031 select {
@@ -1144,7 +1170,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1144 start := time.Now()1170 start := time.Now()
1145 // shows is what the frame puts on screen: presentation feedback1171 // shows is what the frame puts on screen: presentation feedback
1146 // of windows it does not draw is discarded, not presented.1172 // of windows it does not draw is discarded, not presented.
1147- f := pendingFrame{shows: shownBy(scene, seen)}1173+ f := pendingFrame{shows: o.shownBy(scene, seen)}
1148 var fb uint321174 var fb uint32
1149 var c ports.SurfaceContent1175 var c ports.SurfaceContent
1150 forceCompose := len(requests) > 01176 forceCompose := len(requests) > 0
@@ -1153,7 +1179,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1153 }1179 }
1154 direct := false1180 direct := false
1155 if fb != 0 {1181 if fb != 0 {
1156- direct, err = o.commitScanout(fb, c, pendingFrame{shows: map[ports.WindowID]uint64{c.ID: seen[c.ID]}})1182+ direct, err = o.commitScanout(fb, c, pendingFrame{shows: o.directShownBy(c.ID, seen[c.ID])})
1157 }1183 }
1158 if !direct {1184 if !direct {
1159 var ov overlayWin1185 var ov overlayWin
@@ -1312,7 +1338,10 @@ const maxUnsent = 16
1312 // buffers can be released. A flip-less report merges into the newest1338 // buffers can be released. A flip-less report merges into the newest
1313 // queued one; flips are never merged unless maxUnsent is reached.1339 // queued one; flips are never merged unless maxUnsent is reached.
1314 func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {1340 func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
1315- r := ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: maps.Clone(seen)}1341+ if !maps.Equal(o.seenSnapshot, seen) {
1342+ o.seenSnapshot = maps.Clone(seen)
1343+ }
1344+ r := ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: o.seenSnapshot}
1316 if n := len(o.unsent); n > 0 && (flip == nil || o.unsent[n-1].Flip == nil) {1345 if n := len(o.unsent); n > 0 && (flip == nil || o.unsent[n-1].Flip == nil) {
1317 if flip == nil {1346 if flip == nil {
1318 r.Flip = o.unsent[n-1].Flip1347 r.Flip = o.unsent[n-1].Flip
@@ -1325,8 +1354,18 @@ func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
1325 old, next := o.unsent[0].Flip, o.unsent[1].Flip1354 old, next := o.unsent[0].Flip, o.unsent[1].Flip
1326 if next != nil && old != nil {1355 if next != nil && old != nil {
1327 next.Merged += 1 + old.Merged1356 next.Merged += 1 + old.Merged
1357+ // A snapshot can be shared with previously sent flips. Clone only
1358+ // when a merge actually needs to add an absent window.
1359+ cloned := false
1328 for id, seq := range old.Shows {1360 for id, seq := range old.Shows {
1329 if _, ok := next.Shows[id]; !ok {1361 if _, ok := next.Shows[id]; !ok {
1362+ if !cloned {
1363+ next.Shows = maps.Clone(next.Shows)
1364+ cloned = true
1365+ }
1366+ if next.Shows == nil {
1367+ next.Shows = make(map[ports.WindowID]uint64)
1368+ }
1330 next.Shows[id] = seq1369 next.Shows[id] = seq
1331 }1370 }
1332 }1371 }
@@ -1529,13 +1568,28 @@ func (o *Output) freeImages() {
1529 }1568 }
1530 }1569 }
1531 1570
1532-// shownBy is the content Seq of each window the scene draws.1571+// shownBy returns an immutable snapshot of the content Seq of drawn windows.
1533-func shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[ports.WindowID]uint64 {1572+// The scratch map is private to the output; a snapshot may still be read by
1534- out := map[ports.WindowID]uint64{}1573+// wayland after a subsequent flip, so never refill a previously sent map.
1574+func (o *Output) shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[ports.WindowID]uint64 {
1575+ if o.showsScratch == nil {
1576+ o.showsScratch = make(map[ports.WindowID]uint64)
1577+ }
1578+ clear(o.showsScratch)
1535 for id, seq := range seen {1579 for id, seq := range seen {
1536 if s.Shows(id) {1580 if s.Shows(id) {
1537- out[id] = seq1581+ o.showsScratch[id] = seq
1538 }1582 }
1539 }1583 }
1540- return out1584+ if !maps.Equal(o.showsSnapshot, o.showsScratch) {
1585+ o.showsSnapshot = maps.Clone(o.showsScratch)
1586+ }
1587+ return o.showsSnapshot
1588+}
1589+
1590+func (o *Output) directShownBy(id ports.WindowID, seq uint64) map[ports.WindowID]uint64 {
1591+ if len(o.directShowsSnapshot) != 1 || o.directShowsSnapshot[id] != seq {
1592+ o.directShowsSnapshot = map[ports.WindowID]uint64{id: seq}
1593+ }
1594+ return o.directShowsSnapshot
1541 }1595 }
M internal/adapters/drm/output_test.go +43 -0
@@ -218,6 +218,49 @@ func TestFlipReportsTimestampAndShownFrame(t *testing.T) {
218218 }
219219 }
220220
221+// Unchanged reports share an immutable snapshot; a later content update
222+// must not change an earlier report, even after it has been sent.
223+func TestReportSeenAllocations(t *testing.T) {
224+ o, _, _ := testOutput(t)
225+ seen := map[ports.WindowID]uint64{1: 1, 2: 2}
226+ o.report(nil, seen)
227+ // Keep the queue bounded without measuring the queue's growth.
228+ if allocs := testing.AllocsPerRun(100, func() { o.report(nil, seen) }); allocs != 0 {
229+ t.Errorf("unchanged report: %.1f allocs, want 0", allocs)
230+ } else {
231+ t.Logf("unchanged report: %.1f allocs", allocs)
232+ }
233+ previous := o.unsent[0].Seen
234+ seen[1] = 3
235+ o.report(nil, seen)
236+ if previous[1] != 1 || o.unsent[0].Seen[1] != 3 {
237+ t.Fatalf("snapshot mutated: old %v new %v", previous, o.unsent[0].Seen)
238+ }
239+}
240+
241+func TestShownBySnapshotAllocations(t *testing.T) {
242+ o := &Output{}
243+ s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1}}}
244+ seen := map[ports.WindowID]uint64{1: 1, 2: 2}
245+ previous := o.shownBy(s, seen)
246+ if allocs := testing.AllocsPerRun(100, func() { o.shownBy(s, seen) }); allocs != 0 {
247+ t.Errorf("unchanged shows: %.1f allocs, want 0", allocs)
248+ }
249+ seen[1] = 3
250+ current := o.shownBy(s, seen)
251+ if previous[1] != 1 || current[1] != 3 {
252+ t.Fatalf("snapshot mutated: old %v new %v", previous, current)
253+ }
254+ direct := o.directShownBy(1, 3)
255+ if allocs := testing.AllocsPerRun(100, func() { o.directShownBy(1, 3) }); allocs != 0 {
256+ t.Errorf("unchanged direct shows: %.1f allocs, want 0", allocs)
257+ }
258+ o.directShownBy(1, 4)
259+ if direct[1] != 3 {
260+ t.Fatalf("direct snapshot mutated: %v", direct)
261+ }
262+}
263+
221264 func TestReportsKeepEveryFlip(t *testing.T) {
222265 o, _, _ := testOutput(t)
223266 seen := map[ports.WindowID]uint64{1: 1}
@@ -218,6 +218,49 @@ func TestFlipReportsTimestampAndShownFrame(t *testing.T) {
218 }218 }
219 }219 }
220 220
221+// Unchanged reports share an immutable snapshot; a later content update
222+// must not change an earlier report, even after it has been sent.
223+func TestReportSeenAllocations(t *testing.T) {
224+ o, _, _ := testOutput(t)
225+ seen := map[ports.WindowID]uint64{1: 1, 2: 2}
226+ o.report(nil, seen)
227+ // Keep the queue bounded without measuring the queue's growth.
228+ if allocs := testing.AllocsPerRun(100, func() { o.report(nil, seen) }); allocs != 0 {
229+ t.Errorf("unchanged report: %.1f allocs, want 0", allocs)
230+ } else {
231+ t.Logf("unchanged report: %.1f allocs", allocs)
232+ }
233+ previous := o.unsent[0].Seen
234+ seen[1] = 3
235+ o.report(nil, seen)
236+ if previous[1] != 1 || o.unsent[0].Seen[1] != 3 {
237+ t.Fatalf("snapshot mutated: old %v new %v", previous, o.unsent[0].Seen)
238+ }
239+}
240+
241+func TestShownBySnapshotAllocations(t *testing.T) {
242+ o := &Output{}
243+ s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1}}}
244+ seen := map[ports.WindowID]uint64{1: 1, 2: 2}
245+ previous := o.shownBy(s, seen)
246+ if allocs := testing.AllocsPerRun(100, func() { o.shownBy(s, seen) }); allocs != 0 {
247+ t.Errorf("unchanged shows: %.1f allocs, want 0", allocs)
248+ }
249+ seen[1] = 3
250+ current := o.shownBy(s, seen)
251+ if previous[1] != 1 || current[1] != 3 {
252+ t.Fatalf("snapshot mutated: old %v new %v", previous, current)
253+ }
254+ direct := o.directShownBy(1, 3)
255+ if allocs := testing.AllocsPerRun(100, func() { o.directShownBy(1, 3) }); allocs != 0 {
256+ t.Errorf("unchanged direct shows: %.1f allocs, want 0", allocs)
257+ }
258+ o.directShownBy(1, 4)
259+ if direct[1] != 3 {
260+ t.Fatalf("direct snapshot mutated: %v", direct)
261+ }
262+}
263+
221 func TestReportsKeepEveryFlip(t *testing.T) {264 func TestReportsKeepEveryFlip(t *testing.T) {
222 o, _, _ := testOutput(t)265 o, _, _ := testOutput(t)
223 seen := map[ports.WindowID]uint64{1: 1}266 seen := map[ports.WindowID]uint64{1: 1}
M internal/adapters/drm/scanout_test.go +1 -1
@@ -224,7 +224,7 @@ func TestPlaneWithoutInFormatsIsLinear(t *testing.T) {
224224 // a window on another workspace or hidden is discarded, not presented.
225225 func TestShownByScene(t *testing.T) {
226226 s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1}, {ID: 2, Hidden: true}}, Layers: []ports.SceneLayer{{ID: 5}}}
227- got := shownBy(s, map[ports.WindowID]uint64{1: 3, 2: 4, 5: 6, 9: 7})
227+ got := (&Output{}).shownBy(s, map[ports.WindowID]uint64{1: 3, 2: 4, 5: 6, 9: 7})
228228 if len(got) != 2 || got[1] != 3 || got[5] != 6 {
229229 t.Fatalf("shows %v", got)
230230 }
@@ -224,7 +224,7 @@ func TestPlaneWithoutInFormatsIsLinear(t *testing.T) {
224 // a window on another workspace or hidden is discarded, not presented.224 // a window on another workspace or hidden is discarded, not presented.
225 func TestShownByScene(t *testing.T) {225 func TestShownByScene(t *testing.T) {
226 s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1}, {ID: 2, Hidden: true}}, Layers: []ports.SceneLayer{{ID: 5}}}226 s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1}, {ID: 2, Hidden: true}}, Layers: []ports.SceneLayer{{ID: 5}}}
227- got := shownBy(s, map[ports.WindowID]uint64{1: 3, 2: 4, 5: 6, 9: 7})227+ got := (&Output{}).shownBy(s, map[ports.WindowID]uint64{1: 3, 2: 4, 5: 6, 9: 7})
228 if len(got) != 2 || got[1] != 3 || got[5] != 6 {228 if len(got) != 2 || got[1] != 3 || got[5] != 6 {
229 t.Fatalf("shows %v", got)229 t.Fatalf("shows %v", got)
230 }230 }
M internal/adapters/vulkan/cursor.go +1 -1
@@ -32,7 +32,7 @@ const fourccARGB = 'A' | 'R'<<8 | '2'<<16 | '4'<<24
3232 func (r *Renderer) CursorBuffers(size int) ([2]ports.DMABuf, error) {
3333 var out [2]ports.DMABuf
3434 r.dropCursors()
35- if r.dd.GetMemoryFdKHR == nil || len(r.dmabuf.Formats) == 0 || size <= 0 || size > 512 {
35+ if !r.dd.HasGetMemoryFdKHR() || len(r.dmabuf.Formats) == 0 || size <= 0 || size > 512 {
3636 return out, errors.New("device cannot export cursor images")
3737 }
3838 for i := range r.cursors {
@@ -32,7 +32,7 @@ const fourccARGB = 'A' | 'R'<<8 | '2'<<16 | '4'<<24
32 func (r *Renderer) CursorBuffers(size int) ([2]ports.DMABuf, error) {32 func (r *Renderer) CursorBuffers(size int) ([2]ports.DMABuf, error) {
33 var out [2]ports.DMABuf33 var out [2]ports.DMABuf
34 r.dropCursors()34 r.dropCursors()
35- if r.dd.GetMemoryFdKHR == nil || len(r.dmabuf.Formats) == 0 || size <= 0 || size > 512 {35+ if !r.dd.HasGetMemoryFdKHR() || len(r.dmabuf.Formats) == 0 || size <= 0 || size > 512 {
36 return out, errors.New("device cannot export cursor images")36 return out, errors.New("device cannot export cursor images")
37 }37 }
38 for i := range r.cursors {38 for i := range r.cursors {
M internal/adapters/vulkan/damage.go +18 -0
@@ -39,6 +39,24 @@ func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion
3939 return d
4040 }
4141
42+// frameDamage reuses the bookkeeping maps. drawn is swapped with the
43+// target's previous map after hold, so a target never observes mutations.
44+func (r *Renderer) frameDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion {
45+ if r.damageSeen == nil {
46+ r.damageSeen = make(map[ports.WindowID]bool)
47+ }
48+ clear(r.damageSeen)
49+ if r.damageDrawn == nil {
50+ r.damageDrawn = make(map[ports.WindowID]heldWindow)
51+ }
52+ clear(r.damageDrawn)
53+ r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds}
54+ if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq {
55+ r.damage.held = tg
56+ }
57+ return &r.damage
58+}
59+
4260 // all reports whether the whole target is redrawn.
4361 func (d *damageRegion) all() bool { return d.held == nil }
4462
@@ -39,6 +39,24 @@ func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion
39 return d39 return d
40 }40 }
41 41
42+// frameDamage reuses the bookkeeping maps. drawn is swapped with the
43+// target's previous map after hold, so a target never observes mutations.
44+func (r *Renderer) frameDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion {
45+ if r.damageSeen == nil {
46+ r.damageSeen = make(map[ports.WindowID]bool)
47+ }
48+ clear(r.damageSeen)
49+ if r.damageDrawn == nil {
50+ r.damageDrawn = make(map[ports.WindowID]heldWindow)
51+ }
52+ clear(r.damageDrawn)
53+ r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds}
54+ if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq {
55+ r.damage.held = tg
56+ }
57+ return &r.damage
58+}
59+
42 // all reports whether the whole target is redrawn.60 // all reports whether the whole target is redrawn.
43 func (d *damageRegion) all() bool { return d.held == nil }61 func (d *damageRegion) all() bool { return d.held == nil }
44 62
M internal/adapters/vulkan/frame.go +23 -6
@@ -176,7 +176,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
176176 if hdr {
177177 tg = &r.hdrOwn
178178 }
179- dmg := newDamage(tg, s, image.Rect(0, 0, r.width, r.height))
179+ dmg := r.frameDamage(tg, s, image.Rect(0, 0, r.width, r.height))
180180 ds := r.draws(s, contents, dmg)
181181 dmg.finish()
182182 r.dropPools()
@@ -184,7 +184,12 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
184184 if partial {
185185 // The pass keeps the image: repaint the background under the
186186 // region, then everything clipped to it.
187- ds = dmg.clip(append([]draw{r.fillDraw(dmg.area, parseColor(s.Background))}, ds...))
187+ // Keep the background first without allocating a second draw slice.
188+ ds = append(ds, draw{})
189+ copy(ds[1:], ds[:len(ds)-1])
190+ ds[0] = r.fillDraw(dmg.area, parseColor(s.Background))
191+ ds = dmg.clip(ds)
192+ r.scratchDraws = ds
188193 r.redrawn += dmg.area.Dx() * dmg.area.Dy()
189194 } else {
190195 r.redrawn += r.width * r.height
@@ -212,8 +217,12 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
212217 d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit|vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b)
213218 // Client buffers come from the foreign queue family (their driver)
214219 // and go back to it after the frame, so the next frame acquires them.
215- var dmas []*imported
216- acquires := map[*imported]*os.File{}
220+ dmas := r.frameDMAs[:0]
221+ if r.frameAcquires == nil {
222+ r.frameAcquires = make(map[*imported]*os.File)
223+ }
224+ clear(r.frameAcquires)
225+ acquires := r.frameAcquires
217226 for _, dr := range ds {
218227 if dr.im != nil && !slices.Contains(dmas, dr.im) {
219228 dmas = append(dmas, dr.im)
@@ -223,6 +232,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
223232 acquires[dr.im] = dr.acquire
224233 }
225234 }
235+ r.frameDMAs = dmas
226236 r.ownership(cmd, dmas, true)
227237 r.recordDraws(cmd, tg.view, parseColor(s.Background), ds, partial)
228238 r.ownership(cmd, dmas, false)
@@ -247,7 +257,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
247257 }
248258 // Wait for the client's GPU writes to each buffer: its explicit
249259 // acquire fence, else the buffer's implicit fences.
250- var waits []vk.Semaphore
260+ waits := r.frameWaits[:0]
251261 for _, im := range dmas {
252262 if f := acquires[im]; f != nil {
253263 if sem := r.importFence(f); sem != 0 {
@@ -257,12 +267,17 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
257267 waits = r.appendReadFences(waits, im)
258268 }
259269 }
270+ r.frameWaits = waits
260271 frame := r.frame
261272 for _, sem := range waits {
273+ sem := sem
262274 r.retire(frame, func() { d.DestroySemaphore(r.device, sem, nil) })
263275 }
264276 submit := vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &cmd}
265- stages := make([]vk.PipelineStageFlags, len(waits))
277+ if cap(r.frameStages) < len(waits) {
278+ r.frameStages = make([]vk.PipelineStageFlags, len(waits))
279+ }
280+ stages := r.frameStages[:len(waits)]
266281 if len(waits) > 0 {
267282 for i := range stages {
268283 stages[i] = vk.PipelineStageFragmentShaderBit
@@ -285,7 +300,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
285300 } else {
286301 tg.layout = b.NewLayout
287302 }
303+ oldWindows := tg.windows
288304 tg.hold(s, dmg)
305+ r.damageDrawn = oldWindows
289306 r.last, r.readBack = tg, false
290307 r.dropUnused()
291308 r.dropShm()
@@ -176,7 +176,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
176 if hdr {176 if hdr {
177 tg = &r.hdrOwn177 tg = &r.hdrOwn
178 }178 }
179- dmg := newDamage(tg, s, image.Rect(0, 0, r.width, r.height))179+ dmg := r.frameDamage(tg, s, image.Rect(0, 0, r.width, r.height))
180 ds := r.draws(s, contents, dmg)180 ds := r.draws(s, contents, dmg)
181 dmg.finish()181 dmg.finish()
182 r.dropPools()182 r.dropPools()
@@ -184,7 +184,12 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
184 if partial {184 if partial {
185 // The pass keeps the image: repaint the background under the185 // The pass keeps the image: repaint the background under the
186 // region, then everything clipped to it.186 // region, then everything clipped to it.
187- ds = dmg.clip(append([]draw{r.fillDraw(dmg.area, parseColor(s.Background))}, ds...))187+ // Keep the background first without allocating a second draw slice.
188+ ds = append(ds, draw{})
189+ copy(ds[1:], ds[:len(ds)-1])
190+ ds[0] = r.fillDraw(dmg.area, parseColor(s.Background))
191+ ds = dmg.clip(ds)
192+ r.scratchDraws = ds
188 r.redrawn += dmg.area.Dx() * dmg.area.Dy()193 r.redrawn += dmg.area.Dx() * dmg.area.Dy()
189 } else {194 } else {
190 r.redrawn += r.width * r.height195 r.redrawn += r.width * r.height
@@ -212,8 +217,12 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
212 d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit|vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b)217 d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit|vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b)
213 // Client buffers come from the foreign queue family (their driver)218 // Client buffers come from the foreign queue family (their driver)
214 // and go back to it after the frame, so the next frame acquires them.219 // and go back to it after the frame, so the next frame acquires them.
215- var dmas []*imported220+ dmas := r.frameDMAs[:0]
216- acquires := map[*imported]*os.File{}221+ if r.frameAcquires == nil {
222+ r.frameAcquires = make(map[*imported]*os.File)
223+ }
224+ clear(r.frameAcquires)
225+ acquires := r.frameAcquires
217 for _, dr := range ds {226 for _, dr := range ds {
218 if dr.im != nil && !slices.Contains(dmas, dr.im) {227 if dr.im != nil && !slices.Contains(dmas, dr.im) {
219 dmas = append(dmas, dr.im)228 dmas = append(dmas, dr.im)
@@ -223,6 +232,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
223 acquires[dr.im] = dr.acquire232 acquires[dr.im] = dr.acquire
224 }233 }
225 }234 }
235+ r.frameDMAs = dmas
226 r.ownership(cmd, dmas, true)236 r.ownership(cmd, dmas, true)
227 r.recordDraws(cmd, tg.view, parseColor(s.Background), ds, partial)237 r.recordDraws(cmd, tg.view, parseColor(s.Background), ds, partial)
228 r.ownership(cmd, dmas, false)238 r.ownership(cmd, dmas, false)
@@ -247,7 +257,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
247 }257 }
248 // Wait for the client's GPU writes to each buffer: its explicit258 // Wait for the client's GPU writes to each buffer: its explicit
249 // acquire fence, else the buffer's implicit fences.259 // acquire fence, else the buffer's implicit fences.
250- var waits []vk.Semaphore260+ waits := r.frameWaits[:0]
251 for _, im := range dmas {261 for _, im := range dmas {
252 if f := acquires[im]; f != nil {262 if f := acquires[im]; f != nil {
253 if sem := r.importFence(f); sem != 0 {263 if sem := r.importFence(f); sem != 0 {
@@ -257,12 +267,17 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
257 waits = r.appendReadFences(waits, im)267 waits = r.appendReadFences(waits, im)
258 }268 }
259 }269 }
270+ r.frameWaits = waits
260 frame := r.frame271 frame := r.frame
261 for _, sem := range waits {272 for _, sem := range waits {
273+ sem := sem
262 r.retire(frame, func() { d.DestroySemaphore(r.device, sem, nil) })274 r.retire(frame, func() { d.DestroySemaphore(r.device, sem, nil) })
263 }275 }
264 submit := vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &cmd}276 submit := vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &cmd}
265- stages := make([]vk.PipelineStageFlags, len(waits))277+ if cap(r.frameStages) < len(waits) {
278+ r.frameStages = make([]vk.PipelineStageFlags, len(waits))
279+ }
280+ stages := r.frameStages[:len(waits)]
266 if len(waits) > 0 {281 if len(waits) > 0 {
267 for i := range stages {282 for i := range stages {
268 stages[i] = vk.PipelineStageFragmentShaderBit283 stages[i] = vk.PipelineStageFragmentShaderBit
@@ -285,7 +300,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
285 } else {300 } else {
286 tg.layout = b.NewLayout301 tg.layout = b.NewLayout
287 }302 }
303+ oldWindows := tg.windows
288 tg.hold(s, dmg)304 tg.hold(s, dmg)
305+ r.damageDrawn = oldWindows
289 r.last, r.readBack = tg, false306 r.last, r.readBack = tg, false
290 r.dropUnused()307 r.dropUnused()
291 r.dropShm()308 r.dropShm()
M internal/adapters/vulkan/renderer.go +11 -1
@@ -75,6 +75,16 @@ type Renderer struct {
7575 // storage buffer binding.
7676 compose composer
7777 maxRange int
78+ // Scratch belongs to the renderer goroutine; neither slice is published.
79+ scratchDraws []draw
80+ scratchCovers []image.Rectangle
81+ damage damageRegion
82+ damageSeen map[ports.WindowID]bool
83+ damageDrawn map[ports.WindowID]heldWindow
84+ frameDMAs []*imported
85+ frameAcquires map[*imported]*os.File
86+ frameWaits []vk.Semaphore
87+ frameStages []vk.PipelineStageFlags
7888 }
7989
8090 func checked(name string, result vk.Result) error {
@@ -200,7 +210,7 @@ func New(width, height int) (r *Renderer, err error) {
200210 }
201211 // VK_KHR_external_semaphore_fd (enabled above): frames export
202212 // their fence as a sync file.
203- r.syncFD = r.dd.GetSemaphoreFdKHR != nil
213+ r.syncFD = r.dd.HasGetSemaphoreFdKHR()
204214 }
205215 r.dd.GetDeviceQueue(r.device, family, 0, &r.queue)
206216 extent := vk.Extent3D{Width: uint32(width), Height: uint32(height), Depth: 1}
@@ -75,6 +75,16 @@ type Renderer struct {
75 // storage buffer binding.75 // storage buffer binding.
76 compose composer76 compose composer
77 maxRange int77 maxRange int
78+ // Scratch belongs to the renderer goroutine; neither slice is published.
79+ scratchDraws []draw
80+ scratchCovers []image.Rectangle
81+ damage damageRegion
82+ damageSeen map[ports.WindowID]bool
83+ damageDrawn map[ports.WindowID]heldWindow
84+ frameDMAs []*imported
85+ frameAcquires map[*imported]*os.File
86+ frameWaits []vk.Semaphore
87+ frameStages []vk.PipelineStageFlags
78 }88 }
79 89
80 func checked(name string, result vk.Result) error {90 func checked(name string, result vk.Result) error {
@@ -200,7 +210,7 @@ func New(width, height int) (r *Renderer, err error) {
200 }210 }
201 // VK_KHR_external_semaphore_fd (enabled above): frames export211 // VK_KHR_external_semaphore_fd (enabled above): frames export
202 // their fence as a sync file.212 // their fence as a sync file.
203- r.syncFD = r.dd.GetSemaphoreFdKHR != nil213+ r.syncFD = r.dd.HasGetSemaphoreFdKHR()
204 }214 }
205 r.dd.GetDeviceQueue(r.device, family, 0, &r.queue)215 r.dd.GetDeviceQueue(r.device, family, 0, &r.queue)
206 extent := vk.Extent3D{Width: uint32(width), Height: uint32(height), Depth: 1}216 extent := vk.Extent3D{Width: uint32(width), Height: uint32(height), Depth: 1}
M internal/adapters/vulkan/scene.go +6 -2
@@ -24,7 +24,9 @@ type sceneWalk struct {
2424
2525 // draws walks the scene into quads in paint order.
2626 func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, dmg *damageRegion) []draw {
27- w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, bounds: image.Rect(0, 0, r.width, r.height)}
27+ clear(r.scratchDraws) // release references from longer earlier scenes
28+ w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, bounds: image.Rect(0, 0, r.width, r.height), draws: r.scratchDraws[:0]}
29+ r.scratchCovers = r.scratchCovers[:0]
2830 if w.scale <= 0 {
2931 w.scale = 1
3032 }
@@ -36,6 +38,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
3638 w.popups(false)
3739 w.layers(true)
3840 w.popups(true)
41+ r.scratchDraws = w.draws
3942 return w.draws
4043 }
4144
@@ -160,7 +163,7 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y
160163 // left out, so the surface below it still shows: a dmabuf that fails to
161164 // import (opaqueChildren pre-imports it) or an unreadable shm buffer.
162165 func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy int, clip image.Rectangle) []image.Rectangle {
163- var covers []image.Rectangle
166+ covers := w.r.scratchCovers[:0]
164167 for i := range content.Children {
165168 ch := &content.Children[i]
166169 if ch.Below || !ch.Opaque {
@@ -172,6 +175,7 @@ func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy int, cl
172175 }
173176 covers = append(covers, dst)
174177 }
178+ w.r.scratchCovers = covers
175179 return covers
176180 }
177181
@@ -24,7 +24,9 @@ type sceneWalk struct {
24 24
25 // draws walks the scene into quads in paint order.25 // draws walks the scene into quads in paint order.
26 func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, dmg *damageRegion) []draw {26 func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, dmg *damageRegion) []draw {
27- w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, bounds: image.Rect(0, 0, r.width, r.height)}27+ clear(r.scratchDraws) // release references from longer earlier scenes
28+ w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, bounds: image.Rect(0, 0, r.width, r.height), draws: r.scratchDraws[:0]}
29+ r.scratchCovers = r.scratchCovers[:0]
28 if w.scale <= 0 {30 if w.scale <= 0 {
29 w.scale = 131 w.scale = 1
30 }32 }
@@ -36,6 +38,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac
36 w.popups(false)38 w.popups(false)
37 w.layers(true)39 w.layers(true)
38 w.popups(true)40 w.popups(true)
41+ r.scratchDraws = w.draws
39 return w.draws42 return w.draws
40 }43 }
41 44
@@ -160,7 +163,7 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y
160 // left out, so the surface below it still shows: a dmabuf that fails to163 // left out, so the surface below it still shows: a dmabuf that fails to
161 // import (opaqueChildren pre-imports it) or an unreadable shm buffer.164 // import (opaqueChildren pre-imports it) or an unreadable shm buffer.
162 func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy int, clip image.Rectangle) []image.Rectangle {165 func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy int, clip image.Rectangle) []image.Rectangle {
163- var covers []image.Rectangle166+ covers := w.r.scratchCovers[:0]
164 for i := range content.Children {167 for i := range content.Children {
165 ch := &content.Children[i]168 ch := &content.Children[i]
166 if ch.Below || !ch.Opaque {169 if ch.Below || !ch.Opaque {
@@ -172,6 +175,7 @@ func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy int, cl
172 }175 }
173 covers = append(covers, dst)176 covers = append(covers, dst)
174 }177 }
178+ w.r.scratchCovers = covers
175 return covers179 return covers
176 }180 }
177 181
M internal/adapters/vulkan/scene_perf_test.go +38 -2
@@ -7,6 +7,30 @@ import (
77 "github.com/bnema/neferwl/internal/ports"
88 )
99
10+// TestRenderSteadyStateAllocations measures the cost of a small stable scene.
11+func TestRenderSteadyStateAllocations(t *testing.T) {
12+ r, err := New(32, 32)
13+ if err != nil {
14+ t.Skipf("Vulkan unavailable: %v", err)
15+ }
16+ defer r.Close()
17+ scene := ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 32}}}}
18+ content := solidContent(t, 8, 8, color.RGBA{R: 255, A: 255})
19+ content.ID, content.Surface, content.Seq, content.Version = 1, 1, 1, 1
20+ contents := map[ports.WindowID]ports.SurfaceContent{1: content}
21+ frame := func() {
22+ if err := render(r, scene, contents); err != nil {
23+ t.Fatal(err)
24+ }
25+ }
26+ frame()
27+ if allocs := testing.AllocsPerRun(20, frame); allocs > 13 {
28+ t.Errorf("small steady-state Render: %.1f allocs/frame, want <=13", allocs)
29+ } else {
30+ t.Logf("small steady-state Render: %.1f allocs/frame", allocs)
31+ }
32+}
33+
1034 // TestSceneWalkUnchangedTiledSHM guards the GPU copy budget of an unchanged
1135 // subsurface tree. Each child has its own real wl_shm pool.
1236 func TestSceneWalkUnchangedTiledSHM(t *testing.T) {
@@ -37,13 +61,25 @@ func TestSceneWalkUnchangedTiledSHM(t *testing.T) {
3761 _ = r.draws(scene, contents, dmg)
3862 }
3963 walk()
40- if allocs := testing.AllocsPerRun(20, walk); allocs > 16 {
41- t.Errorf("%d tiles: %.1f allocations/walk, want <=16", count, allocs)
64+ if allocs := testing.AllocsPerRun(20, walk); allocs > 8 {
65+ t.Errorf("%d tiles: %.1f allocations/walk, want <=8", count, allocs)
4266 } else {
4367 t.Logf("%d tiles: %.1f allocations/walk", count, allocs)
4468 }
4569 }
4670 contents[1] = root
71+ // The same renderer and scene are reused; measure only steady-state frame work.
72+ frame := func() {
73+ if err := render(r, scene, contents); err != nil {
74+ t.Fatal(err)
75+ }
76+ }
77+ frame()
78+ if allocs := testing.AllocsPerRun(20, frame); allocs > 16 {
79+ t.Errorf("steady-state Render: %.1f allocs/frame, want <=16", allocs)
80+ } else {
81+ t.Logf("steady-state Render: %.1f allocs/frame", allocs)
82+ }
4783 before := r.copied
4884 for i := range 100 {
4985 root.Seq++
@@ -7,6 +7,30 @@ import (
7 "github.com/bnema/neferwl/internal/ports"7 "github.com/bnema/neferwl/internal/ports"
8 )8 )
9 9
10+// TestRenderSteadyStateAllocations measures the cost of a small stable scene.
11+func TestRenderSteadyStateAllocations(t *testing.T) {
12+ r, err := New(32, 32)
13+ if err != nil {
14+ t.Skipf("Vulkan unavailable: %v", err)
15+ }
16+ defer r.Close()
17+ scene := ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 32}}}}
18+ content := solidContent(t, 8, 8, color.RGBA{R: 255, A: 255})
19+ content.ID, content.Surface, content.Seq, content.Version = 1, 1, 1, 1
20+ contents := map[ports.WindowID]ports.SurfaceContent{1: content}
21+ frame := func() {
22+ if err := render(r, scene, contents); err != nil {
23+ t.Fatal(err)
24+ }
25+ }
26+ frame()
27+ if allocs := testing.AllocsPerRun(20, frame); allocs > 13 {
28+ t.Errorf("small steady-state Render: %.1f allocs/frame, want <=13", allocs)
29+ } else {
30+ t.Logf("small steady-state Render: %.1f allocs/frame", allocs)
31+ }
32+}
33+
10 // TestSceneWalkUnchangedTiledSHM guards the GPU copy budget of an unchanged34 // TestSceneWalkUnchangedTiledSHM guards the GPU copy budget of an unchanged
11 // subsurface tree. Each child has its own real wl_shm pool.35 // subsurface tree. Each child has its own real wl_shm pool.
12 func TestSceneWalkUnchangedTiledSHM(t *testing.T) {36 func TestSceneWalkUnchangedTiledSHM(t *testing.T) {
@@ -37,13 +61,25 @@ func TestSceneWalkUnchangedTiledSHM(t *testing.T) {
37 _ = r.draws(scene, contents, dmg)61 _ = r.draws(scene, contents, dmg)
38 }62 }
39 walk()63 walk()
40- if allocs := testing.AllocsPerRun(20, walk); allocs > 16 {64+ if allocs := testing.AllocsPerRun(20, walk); allocs > 8 {
41- t.Errorf("%d tiles: %.1f allocations/walk, want <=16", count, allocs)65+ t.Errorf("%d tiles: %.1f allocations/walk, want <=8", count, allocs)
42 } else {66 } else {
43 t.Logf("%d tiles: %.1f allocations/walk", count, allocs)67 t.Logf("%d tiles: %.1f allocations/walk", count, allocs)
44 }68 }
45 }69 }
46 contents[1] = root70 contents[1] = root
71+ // The same renderer and scene are reused; measure only steady-state frame work.
72+ frame := func() {
73+ if err := render(r, scene, contents); err != nil {
74+ t.Fatal(err)
75+ }
76+ }
77+ frame()
78+ if allocs := testing.AllocsPerRun(20, frame); allocs > 16 {
79+ t.Errorf("steady-state Render: %.1f allocs/frame, want <=16", allocs)
80+ } else {
81+ t.Logf("steady-state Render: %.1f allocs/frame", allocs)
82+ }
47 before := r.copied83 before := r.copied
48 for i := range 100 {84 for i := range 100 {
49 root.Seq++85 root.Seq++
M internal/adapters/vulkan/targets.go +1 -1
@@ -56,7 +56,7 @@ func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, err
5656 if n == 0 {
5757 return nil, nil
5858 }
59- if r.dd.GetMemoryFdKHR == nil || r.dd.GetImageDrmFormatModifierPropertiesEXT == nil || len(r.dmabuf.Formats) == 0 {
59+ if !r.dd.HasGetMemoryFdKHR() || !r.dd.HasGetImageDrmFormatModifierPropertiesEXT() || len(r.dmabuf.Formats) == 0 {
6060 return nil, errors.New("device cannot export dmabufs")
6161 }
6262 mods := r.exportModifiers(modifiers)
@@ -56,7 +56,7 @@ func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, err
56 if n == 0 {56 if n == 0 {
57 return nil, nil57 return nil, nil
58 }58 }
59- if r.dd.GetMemoryFdKHR == nil || r.dd.GetImageDrmFormatModifierPropertiesEXT == nil || len(r.dmabuf.Formats) == 0 {59+ if !r.dd.HasGetMemoryFdKHR() || !r.dd.HasGetImageDrmFormatModifierPropertiesEXT() || len(r.dmabuf.Formats) == 0 {
60 return nil, errors.New("device cannot export dmabufs")60 return nil, errors.New("device cannot export dmabufs")
61 }61 }
62 mods := r.exportModifiers(modifiers)62 mods := r.exportModifiers(modifiers)
M internal/adapters/wayland/fifo.go +71 -5
@@ -1,6 +1,7 @@
11 package wayland
22
33 import (
4+ "sync"
45 "time"
56
67 "github.com/bnema/neferwl/internal/ports"
@@ -233,6 +234,45 @@ type update struct {
233234 bufDamage []ports.Rect
234235 readyGeneration uint64
235236 readyResult bool
237+ // refs counts incoming deps and prev links. Retired updates remain readable
238+ // until their last incoming link goes away, even after dropQueue.
239+ refs int
240+ retired bool
241+}
242+
243+// updatePool contains only retired updates with no incoming graph links. A
244+// popped update is NOT reusable just because it left its owner's queue: a
245+// parent's queued deps can still point to it (including after role teardown).
246+// The display owner goroutine changes graph links; sync.Pool only shares dead,
247+// zeroed storage between independent displays.
248+var updatePool sync.Pool
249+
250+func (u *update) releaseRef() {
251+ u.refs--
252+ if u.refs == 0 && u.retired {
253+ deps := u.deps[:0]
254+ *u = update{deps: deps}
255+ updatePool.Put(u)
256+ }
257+}
258+
259+// retireUpdate severs outgoing links only after the update is no longer used
260+// by applyUpdate/dropQueue. Incoming links keep its identity until consumed.
261+func (u *update) retireUpdate() {
262+ u.retired = true
263+ prev, deps := u.prev, u.deps
264+ u.prev, u.deps = nil, deps[:0]
265+ if prev != nil {
266+ prev.releaseRef()
267+ }
268+ for i, dep := range deps {
269+ deps[i] = nil // the pooled backing array must not retain old dependencies
270+ dep.releaseRef()
271+ }
272+ if u.refs == 0 {
273+ *u = update{deps: u.deps}
274+ updatePool.Put(u)
275+ }
236276 }
237277
238278 type childLayout struct {
@@ -347,7 +387,13 @@ func (s *Server) nextRefresh(name string, now time.Time) time.Time {
347387
348388 // queueUpdate queues the pending commit until it can apply.
349389 func (s *surface) queueUpdate() {
350- u := s.takePending()
390+ u, _ := updatePool.Get().(*update)
391+ if u == nil {
392+ u = new(update)
393+ }
394+ deps := u.deps[:0]
395+ *u = s.takePending()
396+ u.deps = deps
351397 u.owner, u.synced = s, s.effectivelySynced()
352398 for _, ch := range s.sub.children {
353399 for i := len(ch.queue) - 1; i >= 0; i-- {
@@ -355,14 +401,16 @@ func (s *surface) queueUpdate() {
355401 if candidate.synced && !candidate.bound {
356402 candidate.bound = true
357403 u.deps = append(u.deps, candidate)
404+ candidate.refs++
358405 break
359406 }
360407 }
361408 }
362409 if len(s.queue) > 0 {
363410 u.prev = s.queue[len(s.queue)-1]
411+ u.prev.refs++
364412 }
365- s.queue = append(s.queue, &u)
413+ s.queue = append(s.queue, u)
366414 s.server.fifoSurfaces[s] = struct{}{}
367415 s.server.wakePacer()
368416 }
@@ -431,7 +479,11 @@ func (s *surface) dropQueue() {
431479 u.buffer.SendRelease()
432480 }
433481 }
434- s.queue = nil
482+ for i, u := range s.queue {
483+ s.queue[i] = nil // do not retain retired objects in the queue backing array
484+ u.retireUpdate()
485+ }
486+ s.queue = s.queue[:0]
435487 delete(s.server.fifoSurfaces, s)
436488 }
437489
@@ -574,6 +626,9 @@ func (s *surface) flushDesync() {
574626 }
575627
576628 func (u *update) graphReady(now time.Time) bool {
629+ if u.retired {
630+ return true // applied or dropped; a queued parent cannot replay it
631+ }
577632 s := u.owner
578633 gen := s.server.readinessGeneration
579634 if gen != 0 && u.readyGeneration == gen {
@@ -598,6 +653,9 @@ func (u *update) graphReady(now time.Time) bool {
598653 }
599654
600655 func (u *update) applyGraph() {
656+ if u.retired {
657+ return
658+ }
601659 s := u.owner
602660 for len(s.queue) > 0 && s.queue[0] != u {
603661 s.queue[0].applyGraph()
@@ -610,11 +668,19 @@ func (u *update) applyGraph() {
610668 if len(s.queue) == 0 || s.queue[0] != u {
611669 return
612670 }
613- s.queue = s.queue[1:]
671+ s.queue[0] = nil
672+ if len(s.queue) == 1 {
673+ s.queue = s.queue[:0] // retain capacity for the next commit
674+ } else {
675+ s.queue = s.queue[1:]
676+ }
614677 if len(s.queue) > 0 {
615- s.queue[0].prev = nil
678+ next := s.queue[0]
679+ next.prev = nil
680+ u.releaseRef() // next's prev no longer points to u
616681 }
617682 s.applyUpdate(u)
683+ u.retireUpdate()
618684 }
619685
620686 // sameBuffer reports whether two wrappers are the same wl_buffer.
@@ -1,6 +1,7 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4+ "sync"
4 "time"5 "time"
5 6
6 "github.com/bnema/neferwl/internal/ports"7 "github.com/bnema/neferwl/internal/ports"
@@ -233,6 +234,45 @@ type update struct {
233 bufDamage []ports.Rect234 bufDamage []ports.Rect
234 readyGeneration uint64235 readyGeneration uint64
235 readyResult bool236 readyResult bool
237+ // refs counts incoming deps and prev links. Retired updates remain readable
238+ // until their last incoming link goes away, even after dropQueue.
239+ refs int
240+ retired bool
241+}
242+
243+// updatePool contains only retired updates with no incoming graph links. A
244+// popped update is NOT reusable just because it left its owner's queue: a
245+// parent's queued deps can still point to it (including after role teardown).
246+// The display owner goroutine changes graph links; sync.Pool only shares dead,
247+// zeroed storage between independent displays.
248+var updatePool sync.Pool
249+
250+func (u *update) releaseRef() {
251+ u.refs--
252+ if u.refs == 0 && u.retired {
253+ deps := u.deps[:0]
254+ *u = update{deps: deps}
255+ updatePool.Put(u)
256+ }
257+}
258+
259+// retireUpdate severs outgoing links only after the update is no longer used
260+// by applyUpdate/dropQueue. Incoming links keep its identity until consumed.
261+func (u *update) retireUpdate() {
262+ u.retired = true
263+ prev, deps := u.prev, u.deps
264+ u.prev, u.deps = nil, deps[:0]
265+ if prev != nil {
266+ prev.releaseRef()
267+ }
268+ for i, dep := range deps {
269+ deps[i] = nil // the pooled backing array must not retain old dependencies
270+ dep.releaseRef()
271+ }
272+ if u.refs == 0 {
273+ *u = update{deps: u.deps}
274+ updatePool.Put(u)
275+ }
236 }276 }
237 277
238 type childLayout struct {278 type childLayout struct {
@@ -347,7 +387,13 @@ func (s *Server) nextRefresh(name string, now time.Time) time.Time {
347 387
348 // queueUpdate queues the pending commit until it can apply.388 // queueUpdate queues the pending commit until it can apply.
349 func (s *surface) queueUpdate() {389 func (s *surface) queueUpdate() {
350- u := s.takePending()390+ u, _ := updatePool.Get().(*update)
391+ if u == nil {
392+ u = new(update)
393+ }
394+ deps := u.deps[:0]
395+ *u = s.takePending()
396+ u.deps = deps
351 u.owner, u.synced = s, s.effectivelySynced()397 u.owner, u.synced = s, s.effectivelySynced()
352 for _, ch := range s.sub.children {398 for _, ch := range s.sub.children {
353 for i := len(ch.queue) - 1; i >= 0; i-- {399 for i := len(ch.queue) - 1; i >= 0; i-- {
@@ -355,14 +401,16 @@ func (s *surface) queueUpdate() {
355 if candidate.synced && !candidate.bound {401 if candidate.synced && !candidate.bound {
356 candidate.bound = true402 candidate.bound = true
357 u.deps = append(u.deps, candidate)403 u.deps = append(u.deps, candidate)
404+ candidate.refs++
358 break405 break
359 }406 }
360 }407 }
361 }408 }
362 if len(s.queue) > 0 {409 if len(s.queue) > 0 {
363 u.prev = s.queue[len(s.queue)-1]410 u.prev = s.queue[len(s.queue)-1]
411+ u.prev.refs++
364 }412 }
365- s.queue = append(s.queue, &u)413+ s.queue = append(s.queue, u)
366 s.server.fifoSurfaces[s] = struct{}{}414 s.server.fifoSurfaces[s] = struct{}{}
367 s.server.wakePacer()415 s.server.wakePacer()
368 }416 }
@@ -431,7 +479,11 @@ func (s *surface) dropQueue() {
431 u.buffer.SendRelease()479 u.buffer.SendRelease()
432 }480 }
433 }481 }
434- s.queue = nil482+ for i, u := range s.queue {
483+ s.queue[i] = nil // do not retain retired objects in the queue backing array
484+ u.retireUpdate()
485+ }
486+ s.queue = s.queue[:0]
435 delete(s.server.fifoSurfaces, s)487 delete(s.server.fifoSurfaces, s)
436 }488 }
437 489
@@ -574,6 +626,9 @@ func (s *surface) flushDesync() {
574 }626 }
575 627
576 func (u *update) graphReady(now time.Time) bool {628 func (u *update) graphReady(now time.Time) bool {
629+ if u.retired {
630+ return true // applied or dropped; a queued parent cannot replay it
631+ }
577 s := u.owner632 s := u.owner
578 gen := s.server.readinessGeneration633 gen := s.server.readinessGeneration
579 if gen != 0 && u.readyGeneration == gen {634 if gen != 0 && u.readyGeneration == gen {
@@ -598,6 +653,9 @@ func (u *update) graphReady(now time.Time) bool {
598 }653 }
599 654
600 func (u *update) applyGraph() {655 func (u *update) applyGraph() {
656+ if u.retired {
657+ return
658+ }
601 s := u.owner659 s := u.owner
602 for len(s.queue) > 0 && s.queue[0] != u {660 for len(s.queue) > 0 && s.queue[0] != u {
603 s.queue[0].applyGraph()661 s.queue[0].applyGraph()
@@ -610,11 +668,19 @@ func (u *update) applyGraph() {
610 if len(s.queue) == 0 || s.queue[0] != u {668 if len(s.queue) == 0 || s.queue[0] != u {
611 return669 return
612 }670 }
613- s.queue = s.queue[1:]671+ s.queue[0] = nil
672+ if len(s.queue) == 1 {
673+ s.queue = s.queue[:0] // retain capacity for the next commit
674+ } else {
675+ s.queue = s.queue[1:]
676+ }
614 if len(s.queue) > 0 {677 if len(s.queue) > 0 {
615- s.queue[0].prev = nil678+ next := s.queue[0]
679+ next.prev = nil
680+ u.releaseRef() // next's prev no longer points to u
616 }681 }
617 s.applyUpdate(u)682 s.applyUpdate(u)
683+ u.retireUpdate()
618 }684 }
619 685
620 // sameBuffer reports whether two wrappers are the same wl_buffer.686 // sameBuffer reports whether two wrappers are the same wl_buffer.
M internal/adapters/wayland/fifo_test.go +49 -0
@@ -207,6 +207,55 @@ func TestFifoQueuedBufferDestroyed(t *testing.T) {
207207 }
208208 }
209209
210+// A parent can keep a dependency after the child's queue is dropped or its
211+// update is applied independently. Neither path may recycle that dependency
212+// into a new commit while the parent still holds it.
213+func TestQueuedParentRetainsRetiredChild(t *testing.T) {
214+ for _, dropped := range []bool{true, false} {
215+ name := "applied"
216+ if dropped {
217+ name = "dropped"
218+ }
219+ t.Run(name, func(t *testing.T) {
220+ srv := &Server{fifoSurfaces: make(map[*surface]struct{}), frameReady: make(chan struct{}, 1)}
221+ parent := &surface{server: srv}
222+ child := &surface{server: srv}
223+ child.sub.parent, child.sub.synced = parent, true
224+ parent.sub.children = []*surface{child}
225+ child.pendingScale = 2
226+ child.queueUpdate()
227+ old := child.queue[0]
228+ parent.pendingScale = 3
229+ parent.queueUpdate()
230+ pu := parent.queue[0]
231+ if old.refs != 1 || len(pu.deps) != 1 || pu.deps[0] != old {
232+ t.Fatal("parent did not capture child's update")
233+ }
234+ if dropped {
235+ child.dropQueue()
236+ } else {
237+ old.applyGraph()
238+ }
239+ if !old.retired || old.refs != 1 {
240+ t.Fatalf("retired child ref count: retired=%v refs=%d", old.retired, old.refs)
241+ }
242+ child.pendingScale = 7
243+ child.queueUpdate()
244+ if child.queue[0] == old || old.scale != 2 || pu.deps[0] != old {
245+ t.Fatal("live dependency was recycled as a new child commit")
246+ }
247+ if !pu.graphReady(time.Now()) {
248+ t.Fatal("retired dependency kept parent waiting")
249+ }
250+ pu.applyGraph()
251+ if parent.bufferScale != 3 || len(parent.queue) != 0 || len(child.queue) != 1 || child.queue[0].scale != 7 {
252+ t.Fatal("parent replayed old dependency or changed new child commit")
253+ }
254+ child.dropQueue()
255+ })
256+ }
257+}
258+
210259 // Rechecking a shared predecessor in a traversal must reuse its readiness.
211260 func TestGraphReadyMemoizesPredecessors(t *testing.T) {
212261 s := &Server{readinessGeneration: 1}
@@ -207,6 +207,55 @@ func TestFifoQueuedBufferDestroyed(t *testing.T) {
207 }207 }
208 }208 }
209 209
210+// A parent can keep a dependency after the child's queue is dropped or its
211+// update is applied independently. Neither path may recycle that dependency
212+// into a new commit while the parent still holds it.
213+func TestQueuedParentRetainsRetiredChild(t *testing.T) {
214+ for _, dropped := range []bool{true, false} {
215+ name := "applied"
216+ if dropped {
217+ name = "dropped"
218+ }
219+ t.Run(name, func(t *testing.T) {
220+ srv := &Server{fifoSurfaces: make(map[*surface]struct{}), frameReady: make(chan struct{}, 1)}
221+ parent := &surface{server: srv}
222+ child := &surface{server: srv}
223+ child.sub.parent, child.sub.synced = parent, true
224+ parent.sub.children = []*surface{child}
225+ child.pendingScale = 2
226+ child.queueUpdate()
227+ old := child.queue[0]
228+ parent.pendingScale = 3
229+ parent.queueUpdate()
230+ pu := parent.queue[0]
231+ if old.refs != 1 || len(pu.deps) != 1 || pu.deps[0] != old {
232+ t.Fatal("parent did not capture child's update")
233+ }
234+ if dropped {
235+ child.dropQueue()
236+ } else {
237+ old.applyGraph()
238+ }
239+ if !old.retired || old.refs != 1 {
240+ t.Fatalf("retired child ref count: retired=%v refs=%d", old.retired, old.refs)
241+ }
242+ child.pendingScale = 7
243+ child.queueUpdate()
244+ if child.queue[0] == old || old.scale != 2 || pu.deps[0] != old {
245+ t.Fatal("live dependency was recycled as a new child commit")
246+ }
247+ if !pu.graphReady(time.Now()) {
248+ t.Fatal("retired dependency kept parent waiting")
249+ }
250+ pu.applyGraph()
251+ if parent.bufferScale != 3 || len(parent.queue) != 0 || len(child.queue) != 1 || child.queue[0].scale != 7 {
252+ t.Fatal("parent replayed old dependency or changed new child commit")
253+ }
254+ child.dropQueue()
255+ })
256+ }
257+}
258+
210 // Rechecking a shared predecessor in a traversal must reuse its readiness.259 // Rechecking a shared predecessor in a traversal must reuse its readiness.
211 func TestGraphReadyMemoizesPredecessors(t *testing.T) {260 func TestGraphReadyMemoizesPredecessors(t *testing.T) {
212 s := &Server{readinessGeneration: 1}261 s := &Server{readinessGeneration: 1}
M internal/adapters/wayland/frames.go +13 -3
@@ -26,9 +26,14 @@ func (s *Server) pace(ctx context.Context) {
2626 timer := time.NewTimer(defaultFramePeriod)
2727 defer timer.Stop()
2828 presented := s.channels.Presented
29+ if s.flipped == nil {
30+ s.flipped = make(map[string]bool)
31+ }
2932 for {
30- flipped := map[string]bool{}
31- var reports []ports.OutputPresented
33+ clear(s.flipped)
34+ flipped := s.flipped
35+ s.frameReports = s.frameReports[:0]
36+ reports := s.frameReports
3237 select {
3338 case <-ctx.Done():
3439 return
@@ -52,6 +57,7 @@ func (s *Server) pace(ctx context.Context) {
5257 break drain
5358 }
5459 }
60+ s.frameReports = reports
5561 wait, idle := defaultFramePeriod, false
5662 if !s.display.Do(func() {
5763 if ctx.Err() == nil {
@@ -130,7 +136,11 @@ func (s *Server) queueFrames(name string, callbacks []*wayland.Callback) {
130136 // their deadline. wait is the time to the next deadline; idle is set when
131137 // no callback waits after firing.
132138 func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) {
133- periods := make(map[string]time.Duration, len(s.outputs)+1)
139+ if s.framePeriods == nil {
140+ s.framePeriods = make(map[string]time.Duration, len(s.outputs)+1)
141+ }
142+ periods := s.framePeriods
143+ clear(periods)
134144 for _, o := range s.outputs {
135145 periods[o.name()] = framePeriod(o.place.Info.RefreshMilli)
136146 }
@@ -26,9 +26,14 @@ func (s *Server) pace(ctx context.Context) {
26 timer := time.NewTimer(defaultFramePeriod)26 timer := time.NewTimer(defaultFramePeriod)
27 defer timer.Stop()27 defer timer.Stop()
28 presented := s.channels.Presented28 presented := s.channels.Presented
29+ if s.flipped == nil {
30+ s.flipped = make(map[string]bool)
31+ }
29 for {32 for {
30- flipped := map[string]bool{}33+ clear(s.flipped)
31- var reports []ports.OutputPresented34+ flipped := s.flipped
35+ s.frameReports = s.frameReports[:0]
36+ reports := s.frameReports
32 select {37 select {
33 case <-ctx.Done():38 case <-ctx.Done():
34 return39 return
@@ -52,6 +57,7 @@ func (s *Server) pace(ctx context.Context) {
52 break drain57 break drain
53 }58 }
54 }59 }
60+ s.frameReports = reports
55 wait, idle := defaultFramePeriod, false61 wait, idle := defaultFramePeriod, false
56 if !s.display.Do(func() {62 if !s.display.Do(func() {
57 if ctx.Err() == nil {63 if ctx.Err() == nil {
@@ -130,7 +136,11 @@ func (s *Server) queueFrames(name string, callbacks []*wayland.Callback) {
130 // their deadline. wait is the time to the next deadline; idle is set when136 // their deadline. wait is the time to the next deadline; idle is set when
131 // no callback waits after firing.137 // no callback waits after firing.
132 func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) {138 func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) {
133- periods := make(map[string]time.Duration, len(s.outputs)+1)139+ if s.framePeriods == nil {
140+ s.framePeriods = make(map[string]time.Duration, len(s.outputs)+1)
141+ }
142+ periods := s.framePeriods
143+ clear(periods)
134 for _, o := range s.outputs {144 for _, o := range s.outputs {
135 periods[o.name()] = framePeriod(o.place.Info.RefreshMilli)145 periods[o.name()] = framePeriod(o.place.Info.RefreshMilli)
136 }146 }
M internal/adapters/wayland/frames_test.go +11 -0
@@ -9,6 +9,17 @@ import (
99 "github.com/bnema/purego-libwayland/protocol/wayland"
1010 )
1111
12+func TestDueFramesAllocations(t *testing.T) {
13+ s := &Server{frameDue: map[string]time.Time{}, awaiting: map[string][]*wayland.Callback{}}
14+ now := time.Unix(100, 0)
15+ s.dueFrames(now, nil)
16+ if allocs := testing.AllocsPerRun(100, func() { s.dueFrames(now, nil) }); allocs != 0 {
17+ t.Errorf("idle dueFrames: %.1f allocs, want 0", allocs)
18+ } else {
19+ t.Logf("idle dueFrames: %.1f allocs", allocs)
20+ }
21+}
22+
1223 func TestFramePeriod(t *testing.T) {
1324 for _, tc := range []struct {
1425 milli int
@@ -9,6 +9,17 @@ import (
9 "github.com/bnema/purego-libwayland/protocol/wayland"9 "github.com/bnema/purego-libwayland/protocol/wayland"
10 )10 )
11 11
12+func TestDueFramesAllocations(t *testing.T) {
13+ s := &Server{frameDue: map[string]time.Time{}, awaiting: map[string][]*wayland.Callback{}}
14+ now := time.Unix(100, 0)
15+ s.dueFrames(now, nil)
16+ if allocs := testing.AllocsPerRun(100, func() { s.dueFrames(now, nil) }); allocs != 0 {
17+ t.Errorf("idle dueFrames: %.1f allocs, want 0", allocs)
18+ } else {
19+ t.Logf("idle dueFrames: %.1f allocs", allocs)
20+ }
21+}
22+
12 func TestFramePeriod(t *testing.T) {23 func TestFramePeriod(t *testing.T) {
13 for _, tc := range []struct {24 for _, tc := range []struct {
14 milli int25 milli int
M internal/adapters/wayland/input_region.go +26 -24
@@ -34,33 +34,10 @@ func (g *region) boxRect() ports.Rect {
3434
3535 // effectiveInput includes all committed, bounded subsurface regions.
3636 func (s *surface) effectiveInput() (bool, []ports.Rect) {
37- var rects []ports.Rect
38- var visit func(*surface, int, int)
39- visit = func(v *surface, x, y int) {
40- if v.has {
41- bounds := ports.Rect{W: v.content.LogicalW, H: v.content.LogicalH}
42- if v.inputAll {
43- if bounds.W > 0 && bounds.H > 0 {
44- rects = append(rects, ports.Rect{X: x, Y: y, W: bounds.W, H: bounds.H})
45- }
46- } else {
47- for _, r := range v.inputRects {
48- if r = intersectRect(r, bounds); r.W > 0 {
49- r.X += x
50- r.Y += y
51- rects = append(rects, r)
52- }
53- }
54- }
55- }
56- for _, item := range v.sub.layout {
57- visit(item.child, x+item.x, y+item.y)
58- }
59- }
6037 if s.inputAll && len(s.sub.children) == 0 {
6138 return true, nil
6239 }
63- visit(s, 0, 0)
40+ rects := s.appendEffectiveInput(nil, 0, 0)
6441 if s.xdg != nil {
6542 for i := range rects {
6643 rects[i].X -= s.xdg.geometry.X
@@ -70,6 +47,31 @@ func (s *surface) effectiveInput() (bool, []ports.Rect) {
7047 return false, rects
7148 }
7249
50+// appendEffectiveInput walks the committed tree without a per-call recursive
51+// closure. The returned slice belongs to the caller (emitInput publishes it).
52+func (s *surface) appendEffectiveInput(rects []ports.Rect, x, y int) []ports.Rect {
53+ if s.has {
54+ bounds := ports.Rect{W: s.content.LogicalW, H: s.content.LogicalH}
55+ if s.inputAll {
56+ if bounds.W > 0 && bounds.H > 0 {
57+ rects = append(rects, ports.Rect{X: x, Y: y, W: bounds.W, H: bounds.H})
58+ }
59+ } else {
60+ for _, r := range s.inputRects {
61+ if r = intersectRect(r, bounds); r.W > 0 {
62+ r.X += x
63+ r.Y += y
64+ rects = append(rects, r)
65+ }
66+ }
67+ }
68+ }
69+ for _, item := range s.sub.layout {
70+ rects = item.child.appendEffectiveInput(rects, x+item.x, y+item.y)
71+ }
72+ return rects
73+}
74+
7375 // resetInputEmission makes the next mapping publish its current region again.
7476 func (s *surface) resetInputEmission() {
7577 s.sentInput = false
@@ -34,33 +34,10 @@ func (g *region) boxRect() ports.Rect {
34 34
35 // effectiveInput includes all committed, bounded subsurface regions.35 // effectiveInput includes all committed, bounded subsurface regions.
36 func (s *surface) effectiveInput() (bool, []ports.Rect) {36 func (s *surface) effectiveInput() (bool, []ports.Rect) {
37- var rects []ports.Rect
38- var visit func(*surface, int, int)
39- visit = func(v *surface, x, y int) {
40- if v.has {
41- bounds := ports.Rect{W: v.content.LogicalW, H: v.content.LogicalH}
42- if v.inputAll {
43- if bounds.W > 0 && bounds.H > 0 {
44- rects = append(rects, ports.Rect{X: x, Y: y, W: bounds.W, H: bounds.H})
45- }
46- } else {
47- for _, r := range v.inputRects {
48- if r = intersectRect(r, bounds); r.W > 0 {
49- r.X += x
50- r.Y += y
51- rects = append(rects, r)
52- }
53- }
54- }
55- }
56- for _, item := range v.sub.layout {
57- visit(item.child, x+item.x, y+item.y)
58- }
59- }
60 if s.inputAll && len(s.sub.children) == 0 {37 if s.inputAll && len(s.sub.children) == 0 {
61 return true, nil38 return true, nil
62 }39 }
63- visit(s, 0, 0)40+ rects := s.appendEffectiveInput(nil, 0, 0)
64 if s.xdg != nil {41 if s.xdg != nil {
65 for i := range rects {42 for i := range rects {
66 rects[i].X -= s.xdg.geometry.X43 rects[i].X -= s.xdg.geometry.X
@@ -70,6 +47,31 @@ func (s *surface) effectiveInput() (bool, []ports.Rect) {
70 return false, rects47 return false, rects
71 }48 }
72 49
50+// appendEffectiveInput walks the committed tree without a per-call recursive
51+// closure. The returned slice belongs to the caller (emitInput publishes it).
52+func (s *surface) appendEffectiveInput(rects []ports.Rect, x, y int) []ports.Rect {
53+ if s.has {
54+ bounds := ports.Rect{W: s.content.LogicalW, H: s.content.LogicalH}
55+ if s.inputAll {
56+ if bounds.W > 0 && bounds.H > 0 {
57+ rects = append(rects, ports.Rect{X: x, Y: y, W: bounds.W, H: bounds.H})
58+ }
59+ } else {
60+ for _, r := range s.inputRects {
61+ if r = intersectRect(r, bounds); r.W > 0 {
62+ r.X += x
63+ r.Y += y
64+ rects = append(rects, r)
65+ }
66+ }
67+ }
68+ }
69+ for _, item := range s.sub.layout {
70+ rects = item.child.appendEffectiveInput(rects, x+item.x, y+item.y)
71+ }
72+ return rects
73+}
74+
73 // resetInputEmission makes the next mapping publish its current region again.75 // resetInputEmission makes the next mapping publish its current region again.
74 func (s *surface) resetInputEmission() {76 func (s *surface) resetInputEmission() {
75 s.sentInput = false77 s.sentInput = false
M internal/adapters/wayland/server.go +5 -0
@@ -71,6 +71,11 @@ type Server struct {
7171 awaiting map[string][]*wayland.Callback
7272 frameDue map[string]time.Time
7373 frameReady chan struct{}
74+ // Pacer scratch: periods is used on the display goroutine, while
75+ // flipped and frameReports belong to pace and its synchronous Do call.
76+ framePeriods map[string]time.Duration
77+ flipped map[string]bool
78+ frameReports []ports.OutputPresented
7479 // held buffers wait until no output reads them (release.go); reports
7580 // are the outputs' latest ports.OutputPresented.
7681 held []heldBuffer
@@ -71,6 +71,11 @@ type Server struct {
71 awaiting map[string][]*wayland.Callback71 awaiting map[string][]*wayland.Callback
72 frameDue map[string]time.Time72 frameDue map[string]time.Time
73 frameReady chan struct{}73 frameReady chan struct{}
74+ // Pacer scratch: periods is used on the display goroutine, while
75+ // flipped and frameReports belong to pace and its synchronous Do call.
76+ framePeriods map[string]time.Duration
77+ flipped map[string]bool
78+ frameReports []ports.OutputPresented
74 // held buffers wait until no output reads them (release.go); reports79 // held buffers wait until no output reads them (release.go); reports
75 // are the outputs' latest ports.OutputPresented.80 // are the outputs' latest ports.OutputPresented.
76 held []heldBuffer81 held []heldBuffer
M internal/adapters/wayland/surface_perf_test.go +51 -6
@@ -9,9 +9,43 @@ import (
99 "github.com/bnema/neferwl/internal/ports"
1010 )
1111
12+// TestEffectiveInputEmptyTreeAllocations guards against a heap-allocated
13+// recursive closure for every input-region traversal of a tiled tree.
14+func TestEffectiveInputEmptyTreeAllocations(t *testing.T) {
15+ root := &surface{}
16+ for range 64 {
17+ child := &surface{}
18+ root.sub.children = append(root.sub.children, child)
19+ root.sub.layout = append(root.sub.layout, childLayout{child: child})
20+ }
21+ walk := func() {
22+ all, rects := root.effectiveInput()
23+ if all || len(rects) != 0 {
24+ t.Fatal("empty tree has input")
25+ }
26+ }
27+ if allocs := testing.AllocsPerRun(100, walk); allocs != 0 {
28+ t.Errorf("empty tiled input traversal: %.1f allocs, want 0", allocs)
29+ }
30+}
31+
1232 // TestTiledCommitPublishAllocations exercises real content publication without
1333 // a client connection, holding old snapshots while subsequent commits publish.
1434 func TestTiledCommitPublishAllocations(t *testing.T) {
35+ // The top-level test is listed in Makefile perf-check; this subtest guards
36+ // the per-commit update storage after its first retirement.
37+ t.Run("queueUpdateSteadyState", func(t *testing.T) {
38+ srv := &Server{fifoSurfaces: make(map[*surface]struct{}), frameReady: make(chan struct{}, 1)}
39+ surf := &surface{server: srv}
40+ commit := func() {
41+ surf.queueUpdate()
42+ surf.dropQueue()
43+ }
44+ commit()
45+ if allocs := testing.AllocsPerRun(100, commit); allocs != 0 {
46+ t.Errorf("steady-state queueUpdate/dropQueue: %.1f allocs/commit; want 0", allocs)
47+ }
48+ })
1549 for _, count := range []int{16, 64} {
1650 t.Run(fmt.Sprintf("%d", count), func(t *testing.T) {
1751 channels := Channels{Contents: make(chan ports.SurfaceContent, 1)}
@@ -35,8 +69,8 @@ func TestTiledCommitPublishAllocations(t *testing.T) {
3569 if first.Seq != 1 || len(first.Children) != count || len(first.DamageHistory) != 1 {
3670 t.Fatalf("previous publication changed: %+v", first)
3771 }
38- if allocs := testing.AllocsPerRun(100, publish); allocs > 4 {
39- t.Errorf("%d children: %.1f allocs/publication; want <=4", count, allocs)
72+ if allocs := testing.AllocsPerRun(100, publish); allocs > 2 {
73+ t.Errorf("%d children: %.1f allocs/publication; want <=2", count, allocs)
4074 } else {
4175 t.Logf("%d children: %.1f allocs/unchanged publication", count, allocs)
4276 }
@@ -44,8 +78,19 @@ func TestTiledCommitPublishAllocations(t *testing.T) {
4478 // proportionally to the number of children.
4579 root.sub.pendingLayout = root.sub.layout
4680 queue := func() { _ = root.takePending() }
47- if allocs := testing.AllocsPerRun(100, queue); allocs > 2 {
48- t.Errorf("%d children: %.1f allocs/queued commit; want <=2", count, allocs)
81+ if allocs := testing.AllocsPerRun(100, queue); allocs > 0 {
82+ t.Errorf("%d children: %.1f allocs/queued commit; want 0", count, allocs)
83+ } else {
84+ t.Logf("%d children: %.1f allocs/takePending", count, allocs)
85+ }
86+ commit := func() {
87+ root.queueUpdate()
88+ root.dropQueue()
89+ }
90+ if allocs := testing.AllocsPerRun(100, commit); allocs != 0 {
91+ t.Errorf("%d children: %.1f allocs/queueUpdate; want 0", count, allocs)
92+ } else {
93+ t.Logf("%d children: %.1f allocs/queueUpdate", count, allocs)
4994 }
5095 if first.Seq != 1 || len(first.Children) != count || len(first.DamageHistory) != 1 || first.DamageHistory[0].Seq != 1 {
5196 t.Fatal("previous publication was mutated")
@@ -68,8 +113,8 @@ func TestTiledCommitPublishAllocations(t *testing.T) {
68113 root.treeDirty = true
69114 root.redraw()
70115 }
71- if allocs := testing.AllocsPerRun(100, changed); allocs > 4 {
72- t.Errorf("%d children: %.1f allocs/changed publication; want <=4", count, allocs)
116+ if allocs := testing.AllocsPerRun(100, changed); allocs > 3 {
117+ t.Errorf("%d children: %.1f allocs/changed publication; want <=3", count, allocs)
73118 } else {
74119 t.Logf("%d children: %.1f allocs/changed publication", count, allocs)
75120 }
@@ -9,9 +9,43 @@ import (
9 "github.com/bnema/neferwl/internal/ports"9 "github.com/bnema/neferwl/internal/ports"
10 )10 )
11 11
12+// TestEffectiveInputEmptyTreeAllocations guards against a heap-allocated
13+// recursive closure for every input-region traversal of a tiled tree.
14+func TestEffectiveInputEmptyTreeAllocations(t *testing.T) {
15+ root := &surface{}
16+ for range 64 {
17+ child := &surface{}
18+ root.sub.children = append(root.sub.children, child)
19+ root.sub.layout = append(root.sub.layout, childLayout{child: child})
20+ }
21+ walk := func() {
22+ all, rects := root.effectiveInput()
23+ if all || len(rects) != 0 {
24+ t.Fatal("empty tree has input")
25+ }
26+ }
27+ if allocs := testing.AllocsPerRun(100, walk); allocs != 0 {
28+ t.Errorf("empty tiled input traversal: %.1f allocs, want 0", allocs)
29+ }
30+}
31+
12 // TestTiledCommitPublishAllocations exercises real content publication without32 // TestTiledCommitPublishAllocations exercises real content publication without
13 // a client connection, holding old snapshots while subsequent commits publish.33 // a client connection, holding old snapshots while subsequent commits publish.
14 func TestTiledCommitPublishAllocations(t *testing.T) {34 func TestTiledCommitPublishAllocations(t *testing.T) {
35+ // The top-level test is listed in Makefile perf-check; this subtest guards
36+ // the per-commit update storage after its first retirement.
37+ t.Run("queueUpdateSteadyState", func(t *testing.T) {
38+ srv := &Server{fifoSurfaces: make(map[*surface]struct{}), frameReady: make(chan struct{}, 1)}
39+ surf := &surface{server: srv}
40+ commit := func() {
41+ surf.queueUpdate()
42+ surf.dropQueue()
43+ }
44+ commit()
45+ if allocs := testing.AllocsPerRun(100, commit); allocs != 0 {
46+ t.Errorf("steady-state queueUpdate/dropQueue: %.1f allocs/commit; want 0", allocs)
47+ }
48+ })
15 for _, count := range []int{16, 64} {49 for _, count := range []int{16, 64} {
16 t.Run(fmt.Sprintf("%d", count), func(t *testing.T) {50 t.Run(fmt.Sprintf("%d", count), func(t *testing.T) {
17 channels := Channels{Contents: make(chan ports.SurfaceContent, 1)}51 channels := Channels{Contents: make(chan ports.SurfaceContent, 1)}
@@ -35,8 +69,8 @@ func TestTiledCommitPublishAllocations(t *testing.T) {
35 if first.Seq != 1 || len(first.Children) != count || len(first.DamageHistory) != 1 {69 if first.Seq != 1 || len(first.Children) != count || len(first.DamageHistory) != 1 {
36 t.Fatalf("previous publication changed: %+v", first)70 t.Fatalf("previous publication changed: %+v", first)
37 }71 }
38- if allocs := testing.AllocsPerRun(100, publish); allocs > 4 {72+ if allocs := testing.AllocsPerRun(100, publish); allocs > 2 {
39- t.Errorf("%d children: %.1f allocs/publication; want <=4", count, allocs)73+ t.Errorf("%d children: %.1f allocs/publication; want <=2", count, allocs)
40 } else {74 } else {
41 t.Logf("%d children: %.1f allocs/unchanged publication", count, allocs)75 t.Logf("%d children: %.1f allocs/unchanged publication", count, allocs)
42 }76 }
@@ -44,8 +78,19 @@ func TestTiledCommitPublishAllocations(t *testing.T) {
44 // proportionally to the number of children.78 // proportionally to the number of children.
45 root.sub.pendingLayout = root.sub.layout79 root.sub.pendingLayout = root.sub.layout
46 queue := func() { _ = root.takePending() }80 queue := func() { _ = root.takePending() }
47- if allocs := testing.AllocsPerRun(100, queue); allocs > 2 {81+ if allocs := testing.AllocsPerRun(100, queue); allocs > 0 {
48- t.Errorf("%d children: %.1f allocs/queued commit; want <=2", count, allocs)82+ t.Errorf("%d children: %.1f allocs/queued commit; want 0", count, allocs)
83+ } else {
84+ t.Logf("%d children: %.1f allocs/takePending", count, allocs)
85+ }
86+ commit := func() {
87+ root.queueUpdate()
88+ root.dropQueue()
89+ }
90+ if allocs := testing.AllocsPerRun(100, commit); allocs != 0 {
91+ t.Errorf("%d children: %.1f allocs/queueUpdate; want 0", count, allocs)
92+ } else {
93+ t.Logf("%d children: %.1f allocs/queueUpdate", count, allocs)
49 }94 }
50 if first.Seq != 1 || len(first.Children) != count || len(first.DamageHistory) != 1 || first.DamageHistory[0].Seq != 1 {95 if first.Seq != 1 || len(first.Children) != count || len(first.DamageHistory) != 1 || first.DamageHistory[0].Seq != 1 {
51 t.Fatal("previous publication was mutated")96 t.Fatal("previous publication was mutated")
@@ -68,8 +113,8 @@ func TestTiledCommitPublishAllocations(t *testing.T) {
68 root.treeDirty = true113 root.treeDirty = true
69 root.redraw()114 root.redraw()
70 }115 }
71- if allocs := testing.AllocsPerRun(100, changed); allocs > 4 {116+ if allocs := testing.AllocsPerRun(100, changed); allocs > 3 {
72- t.Errorf("%d children: %.1f allocs/changed publication; want <=4", count, allocs)117+ t.Errorf("%d children: %.1f allocs/changed publication; want <=3", count, allocs)
73 } else {118 } else {
74 t.Logf("%d children: %.1f allocs/changed publication", count, allocs)119 t.Logf("%d children: %.1f allocs/changed publication", count, allocs)
75 }120 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close